diff --git a/packages/app/package.json b/packages/app/package.json index 7bc709cc..997d0421 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -22,6 +22,7 @@ "@gooddollar/good-design": "^0.1.31", "@gooddollar/goodcollective-sdk": "^1.0.15", "@gooddollar/web3sdk-v2": "^0.2.2", + "@nerdwallet/apollo-cache-policies": "^3.2.0", "@react-native-aria/interactions": "0.2.3", "@react-native-async-storage/async-storage": "^1.18.2", "@react-native-firebase/analytics": "16.7.0", @@ -67,6 +68,7 @@ "react-native-web": "0.18.*", "react-router-dom": "^6.14.1", "react-router-native": "^6.14.1", + "realm-web": "^2.0.0", "viem": "^1.10.8", "wagmi": "^1.4.5" }, diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index dd773e6e..17458209 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -19,12 +19,11 @@ import { publicProvider } from 'wagmi/providers/public'; import { infuraProvider } from 'wagmi/providers/infura'; import { MetaMaskConnector } from 'wagmi/connectors/metaMask'; import { WalletConnectConnector } from 'wagmi/connectors/walletConnect'; -import { ApolloClient, ApolloProvider, InMemoryCache, NormalizedCacheObject } from '@apollo/client'; +import { ApolloProvider } from '@apollo/client'; import { Colors } from './utils/colors'; -import { AsyncStorageWrapper, persistCache } from 'apollo3-cache-persist'; -import AsyncStorage from '@react-native-async-storage/async-storage'; -import { useEffect, useState } from 'react'; +import { useCreateSubgraphApolloClient, useCreateMongoDbApolloClient } from './hooks/apollo'; +import { MongoDbApolloProvider } from './components/providers/MongoDbApolloProvider'; function App(): JSX.Element { const { publicClient, webSocketPublicClient } = configureChains( @@ -44,30 +43,6 @@ function App(): JSX.Element { }), ]; - const [apolloClient, setApolloClient] = useState | undefined>(); - - useEffect(() => { - async function initApollo() { - const cache = new InMemoryCache(); - await persistCache({ - cache, - storage: new AsyncStorageWrapper(AsyncStorage), - }); - const client = new ApolloClient({ - uri: 'https://api.thegraph.com/subgraphs/name/gooddollar/goodcollective', - cache, - defaultOptions: { - watchQuery: { - fetchPolicy: 'cache-and-network', - }, - }, - }); - setApolloClient(client); - } - - initApollo().catch(console.error); - }, []); - const wagmiConfig = createConfig({ autoConnect: true, connectors, @@ -75,46 +50,51 @@ function App(): JSX.Element { webSocketPublicClient, }); - if (!apolloClient) { + const subgraphApolloClient = useCreateSubgraphApolloClient(); + const mongoDbApolloClient = useCreateMongoDbApolloClient(); + + if (!subgraphApolloClient || !mongoDbApolloClient) { return Loading...; } return ( - - - {Platform.OS !== 'web' && ( - - - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - - )} + + + + {Platform.OS !== 'web' && ( + + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + )} - {Platform.OS === 'web' && ( - - - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - - )} - + {Platform.OS === 'web' && ( + + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + )} + + diff --git a/packages/app/src/components/DonorsList/DonorsList.tsx b/packages/app/src/components/DonorsList/DonorsList.tsx index 893e1df6..a7a054f6 100644 --- a/packages/app/src/components/DonorsList/DonorsList.tsx +++ b/packages/app/src/components/DonorsList/DonorsList.tsx @@ -3,6 +3,7 @@ import { DonorCollective } from '../../models/models'; import { DonorsListItem } from './DonorsListItem'; import { useMemo } from 'react'; import Decimal from 'decimal.js'; +import { useFetchFullNames } from '../../hooks/useFetchFullName'; interface DonorsListProps { donors: DonorCollective[]; @@ -20,10 +21,15 @@ function DonorsList({ donors, listStyle }: DonorsListProps) { }); }, [donors]); + const userAddresses = useMemo(() => { + return donors.map((donor) => donor.donor as `0x${string}`); + }, [donors]); + const userFullNames = useFetchFullNames(userAddresses); + return ( {sortedDonors.map((donor, index) => ( - + ))} ); diff --git a/packages/app/src/components/DonorsList/DonorsListItem.tsx b/packages/app/src/components/DonorsList/DonorsListItem.tsx index 2d213269..16662208 100644 --- a/packages/app/src/components/DonorsList/DonorsListItem.tsx +++ b/packages/app/src/components/DonorsList/DonorsListItem.tsx @@ -11,16 +11,17 @@ import { useEnsName } from 'wagmi'; interface DonorsListItemProps { donor: DonorCollective; rank: number; + userFullName?: string; } export const DonorsListItem = (props: DonorsListItemProps) => { - const { donor, rank } = props; + const { donor, rank, userFullName } = props; const { navigate } = useCrossNavigate(); const formattedDonations: string = new Decimal(ethers.utils.formatEther(donor.contribution) ?? 0).toString(); const { data: ensName } = useEnsName({ address: donor.donor as `0x${string}`, chainId: 1 }); - const userIdentifier = ensName ? ensName : formatAddress(donor.donor, 5); + const userIdentifier = userFullName ?? ensName ?? formatAddress(donor.donor); const circleBackgroundColor = rank === 1 ? Colors.yellow[100] : rank === 2 ? Colors.gray[700] : Colors.orange[400]; const circleTextColor = rank === 1 ? Colors.yellow[200] : rank === 2 ? Colors.blue[200] : Colors.brown[100]; diff --git a/packages/app/src/components/StewardsList/StewardsList.tsx b/packages/app/src/components/StewardsList/StewardsList.tsx index a77baa8a..797bea3c 100644 --- a/packages/app/src/components/StewardsList/StewardsList.tsx +++ b/packages/app/src/components/StewardsList/StewardsList.tsx @@ -6,6 +6,7 @@ import { StewardCollective } from '../../models/models'; import { useMemo } from 'react'; import { profilePictures } from '../../utils/profilePictures'; import { StewardBlue, StewardGreen } from '../../assets'; +import { useFetchFullNames } from '../../hooks/useFetchFullName'; interface StewardListProps { listType: 'viewCollective' | 'viewStewards'; @@ -22,6 +23,11 @@ function StewardList({ listType, stewards, titleStyle, listStyle }: StewardListP return profilePictures.sort(() => Math.random()); }, []); + const userAddresses = useMemo(() => { + return stewards.map((steward) => steward.steward as `0x${string}`); + }, [stewards]); + const userFullNames = useFetchFullNames(userAddresses); + return ( @@ -35,6 +41,7 @@ function StewardList({ listType, stewards, titleStyle, listStyle }: StewardListP showActions={listType === 'viewStewards'} key={steward.steward} profileImage={profileImages[index % profileImages.length]} + userFullName={userFullNames[index]} /> ))} diff --git a/packages/app/src/components/StewardsList/StewardsListItem.tsx b/packages/app/src/components/StewardsList/StewardsListItem.tsx index 7f9b64ee..586e72cd 100644 --- a/packages/app/src/components/StewardsList/StewardsListItem.tsx +++ b/packages/app/src/components/StewardsList/StewardsListItem.tsx @@ -12,16 +12,17 @@ interface StewardListItemProps { steward: StewardCollective; showActions: boolean; profileImage: string; + userFullName?: string; } export const StewardsListItem = (props: StewardListItemProps) => { - const { showActions, steward, profileImage } = props; + const { showActions, steward, profileImage, userFullName } = props; const { navigate } = useCrossNavigate(); const isVerified = useIsStewardVerified(steward.steward); const { data: ensName } = useEnsName({ address: steward.steward as `0x${string}`, chainId: 1 }); - const userIdentifier = ensName ? ensName : formatAddress(steward.steward, 5); + const userIdentifier = userFullName ?? ensName ?? formatAddress(steward.steward); const onClickSteward = () => navigate(`/profile/${steward.steward}`); diff --git a/packages/app/src/components/TransactionList/ClaimTransactionListItem.tsx b/packages/app/src/components/TransactionList/ClaimTransactionListItem.tsx index baa6e714..8527d41f 100644 --- a/packages/app/src/components/TransactionList/ClaimTransactionListItem.tsx +++ b/packages/app/src/components/TransactionList/ClaimTransactionListItem.tsx @@ -2,6 +2,7 @@ import { ClaimTx } from '../../models/models'; import { formatAddress } from '../../lib/formatAddress'; import { useEnsName } from 'wagmi'; import TransactionListItem from './TransactionListItem'; +import { useFetchFullName } from '../../hooks/useFetchFullName'; interface ClaimTransactionListItemProps { transaction: ClaimTx; @@ -20,8 +21,9 @@ export function ClaimTransactionListItem({ transaction }: ClaimTransactionListIt } const { data: ensName } = useEnsName({ address: userAddress, chainId: 1 }); - // TODO: how to get first name and last name of users? - const userIdentifier = multipleStewardsText ?? ensName ?? (userAddress ? formatAddress(userAddress) : 'Unknown'); + const userFullName = useFetchFullName(userAddress); + const userIdentifier = + multipleStewardsText ?? userFullName ?? ensName ?? (userAddress ? formatAddress(userAddress) : 'Unknown'); return ( ; +}; + +const MongoDbApolloContext = createContext | undefined>(undefined); + +export const MongoDbApolloProvider = ({ children, client }: PropsWithChildren) => { + return {children}; +}; + +export const useMongoDbApolloClient = (): ApolloClient => { + const context = useContext(MongoDbApolloContext); + if (!context) { + throw new Error('useMongoDbApollo must be used within a MongoDbApolloProvider'); + } + return context; +}; diff --git a/packages/app/src/hooks/apollo/index.ts b/packages/app/src/hooks/apollo/index.ts new file mode 100644 index 00000000..ebdd164a --- /dev/null +++ b/packages/app/src/hooks/apollo/index.ts @@ -0,0 +1,2 @@ +export * from './useCreateMongoDbApolloClient'; +export * from './useCreateSubgraphApolloClient'; diff --git a/packages/app/src/hooks/apollo/useCreateMongoDbApolloClient.ts b/packages/app/src/hooks/apollo/useCreateMongoDbApolloClient.ts new file mode 100644 index 00000000..dd7f11ee --- /dev/null +++ b/packages/app/src/hooks/apollo/useCreateMongoDbApolloClient.ts @@ -0,0 +1,76 @@ +import { ApolloClient, HttpLink, NormalizedCacheObject } from '@apollo/client'; +import { useEffect, useState } from 'react'; +import * as Realm from 'realm-web'; +import { InvalidationPolicyCache, RenewalPolicy } from '@nerdwallet/apollo-cache-policies'; +import { AsyncStorageWrapper, persistCache } from 'apollo3-cache-persist'; +import AsyncStorage from '@react-native-async-storage/async-storage'; + +const APP_ID = 'wallet_prod-obclo'; +const mongoDbUri = `https://realm.mongodb.com/api/client/v2.0/app/${APP_ID}/graphql`; + +export const useCreateMongoDbApolloClient = (): ApolloClient | undefined => { + const [apolloClient, setApolloClient] = useState | undefined>(); + + useEffect(() => { + async function initApollo() { + const app = new Realm.App(APP_ID); + + async function getValidAccessToken() { + if (!app.currentUser) { + await app.logIn(Realm.Credentials.anonymous()); + } else { + await app.currentUser.refreshCustomData(); + } + return app.currentUser?.accessToken; + } + + const cache = new InvalidationPolicyCache({ + invalidationPolicies: { + timeToLive: 3600 * 1000, // 24hr TTL on all types in the cache + renewalPolicy: RenewalPolicy.AccessAndWrite, + types: { + User_profile: { + timeToLive: 3600 * 1000 * 24, // 1 day + }, + }, + }, + }); + + await persistCache({ + cache, + storage: new AsyncStorageWrapper(AsyncStorage), + }); + + const client = new ApolloClient({ + cache, + link: new HttpLink({ + uri: mongoDbUri, + fetch: async (uri, options) => { + const accessToken = await getValidAccessToken(); + if (!options) { + options = {}; + } + if (!options.headers) { + options.headers = {}; + } + (options.headers as Record).Authorization = `Bearer ${accessToken}`; + return fetch(uri, options); + }, + }), + defaultOptions: { + watchQuery: { + fetchPolicy: 'cache-and-network', + }, + query: { + fetchPolicy: 'cache-first', + }, + }, + }); + setApolloClient(client); + } + + initApollo().catch(console.error); + }, []); + + return apolloClient; +}; diff --git a/packages/app/src/hooks/apollo/useCreateSubgraphApolloClient.ts b/packages/app/src/hooks/apollo/useCreateSubgraphApolloClient.ts new file mode 100644 index 00000000..30392758 --- /dev/null +++ b/packages/app/src/hooks/apollo/useCreateSubgraphApolloClient.ts @@ -0,0 +1,34 @@ +import { useEffect, useState } from 'react'; +import { ApolloClient, InMemoryCache, NormalizedCacheObject } from '@apollo/client'; +import { AsyncStorageWrapper, persistCache } from 'apollo3-cache-persist'; +import AsyncStorage from '@react-native-async-storage/async-storage'; + +const subgraphUri = 'https://api.thegraph.com/subgraphs/name/gooddollar/goodcollective'; + +export const useCreateSubgraphApolloClient = (): ApolloClient | undefined => { + const [apolloClient, setApolloClient] = useState | undefined>(); + + useEffect(() => { + async function initApollo() { + const cache = new InMemoryCache(); + await persistCache({ + cache, + storage: new AsyncStorageWrapper(AsyncStorage), + }); + const client = new ApolloClient({ + uri: subgraphUri, + cache, + defaultOptions: { + watchQuery: { + fetchPolicy: 'cache-and-network', + }, + }, + }); + setApolloClient(client); + } + + initApollo().catch(console.error); + }, []); + + return apolloClient; +}; diff --git a/packages/app/src/hooks/apollo/useMongoDbQuery.ts b/packages/app/src/hooks/apollo/useMongoDbQuery.ts new file mode 100644 index 00000000..5681f003 --- /dev/null +++ b/packages/app/src/hooks/apollo/useMongoDbQuery.ts @@ -0,0 +1,25 @@ +import { DocumentNode } from 'graphql/language'; +import { ApolloError, TypedDocumentNode } from '@apollo/client'; +import React, { useEffect } from 'react'; +import { useMongoDbApolloClient } from '../../components/providers/MongoDbApolloProvider'; + +export function useMongoDbQuery = Record>( + query: DocumentNode | TypedDocumentNode, + options?: Record +): { data?: TData; loading: boolean; error?: ApolloError } { + const client = useMongoDbApolloClient(); + + const [data, setData] = React.useState(); + const [loading, setLoading] = React.useState(true); + const [error, setError] = React.useState(); + + useEffect(() => { + client.query({ query, ...options }).then((result) => { + setData(result.data); + setLoading(result.loading); + setError(result.error); + }); + }, [client, options, query]); + + return { data, loading, error }; +} diff --git a/packages/app/src/hooks/useFetchFullName.ts b/packages/app/src/hooks/useFetchFullName.ts new file mode 100644 index 00000000..424ee79c --- /dev/null +++ b/packages/app/src/hooks/useFetchFullName.ts @@ -0,0 +1,48 @@ +import { ethers } from 'ethers'; +import { useMemo } from 'react'; +import { gql } from '@apollo/client'; +import { useMongoDbQuery } from './apollo/useMongoDbQuery'; + +interface UserProfile { + fullName?: { display?: string }; +} + +interface UserProfilesResponse { + user_profiles: (UserProfile | undefined)[]; +} + +const findProfiles = gql` + query FindProfiles($query: User_profileQueryInput!) { + user_profiles(query: $query) { + fullName { + display + } + } + } +`; + +export function useFetchFullName(address?: string): string | undefined { + const names = useFetchFullNames(address ? [address] : []); + if (!names || names.length === 0) return undefined; + return names[0]; +} + +export function useFetchFullNames(addresses: string[]): (string | undefined)[] { + const hashedAddresses = useMemo(() => { + return addresses.map((address: string) => ethers.utils.keccak256(address)); + }, [addresses]); + + const { data, error } = useMongoDbQuery(findProfiles, { + variables: { query: { index: { walletAddress: { hash_in: hashedAddresses } } } }, + }); + + return useMemo(() => { + if (error) { + console.error(error); + } + if (!data || data.user_profiles.length === 0) { + return []; + } + return data.user_profiles.map((profile) => profile?.fullName?.display); + }, [data, error]); +} diff --git a/packages/app/src/pages/WalletProfilePage.tsx b/packages/app/src/pages/WalletProfilePage.tsx index cf1fff01..e3624ab6 100644 --- a/packages/app/src/pages/WalletProfilePage.tsx +++ b/packages/app/src/pages/WalletProfilePage.tsx @@ -6,6 +6,7 @@ import Breadcrumb from '../components/Breadcrumb'; import { useMediaQuery } from 'native-base'; import WalletProfile from '../components/WalletProfile'; import { useEnsName } from 'wagmi'; +import { useFetchFullName } from '../hooks/useFetchFullName'; function WalletProfilePage() { const location = useLocation(); @@ -23,9 +24,8 @@ function WalletProfilePage() { const { data: ensName } = useEnsName({ address, chainId: 1 }); - // TODO: how to get first name and last name of users? - const firstName = profileAddress ? 'Wonderful' : 'Not'; - const lastName = profileAddress ? 'Person' : 'Connected'; + const fullName = useFetchFullName(address); + const [firstName, lastName] = fullName?.trim().split(' ') ?? [undefined, undefined]; const userIdentifier = firstName ? `${firstName} ${lastName}` : ensName ?? address ?? '0x'; diff --git a/packages/app/src/subgraph/useSubgraphData.ts b/packages/app/src/subgraph/useSubgraphData.ts index 3ac2706d..aa45007a 100644 --- a/packages/app/src/subgraph/useSubgraphData.ts +++ b/packages/app/src/subgraph/useSubgraphData.ts @@ -1,4 +1,4 @@ -import { LazyQueryHookOptions, OperationVariables, TypedDocumentNode, useQuery } from '@apollo/client'; +import { OperationVariables, QueryHookOptions, TypedDocumentNode, useQuery } from '@apollo/client'; import { DocumentNode } from 'graphql/language'; import { SubgraphClaim, @@ -20,7 +20,7 @@ export type SupportEventsSubgraphResponse = { supportEvents?: SubgraphSupportEve export function useSubgraphData( query: DocumentNode | TypedDocumentNode, - options?: LazyQueryHookOptions + options?: QueryHookOptions ): | CollectivesSubgraphResponse | DonorsSubgraphResponse diff --git a/yarn.lock b/yarn.lock index 7f2dc580..73208a23 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4532,6 +4532,7 @@ __metadata: "@gooddollar/good-design": ^0.1.31 "@gooddollar/goodcollective-sdk": ^1.0.15 "@gooddollar/web3sdk-v2": ^0.2.2 + "@nerdwallet/apollo-cache-policies": ^3.2.0 "@react-native-aria/interactions": 0.2.3 "@react-native-async-storage/async-storage": ^1.18.2 "@react-native-community/eslint-config": ^3.2.0 @@ -4597,6 +4598,7 @@ __metadata: react-router-dom: ^6.14.1 react-router-native: ^6.14.1 react-test-renderer: 18.2.0 + realm-web: ^2.0.0 typechain: ^8.1.1 typescript: ^5.1.3 vercel: latest @@ -5751,6 +5753,20 @@ __metadata: languageName: node linkType: hard +"@nerdwallet/apollo-cache-policies@npm:^3.2.0": + version: 3.2.0 + resolution: "@nerdwallet/apollo-cache-policies@npm:3.2.0" + dependencies: + graphql: ^15.5.0 + lodash: ^4.17.21 + uuid: ^7.0.3 + peerDependencies: + "@apollo/client": ^3.8.1 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + checksum: 22eb609bf4e9fff59e147dbf165ab05e5ff419450b77ec65126837c674cc942e36ee117c119cca58b860de21ff13e6c5f747f40e2f32b2e351fd4111420e9a23 + languageName: node + linkType: hard + "@nicolo-ribaudo/eslint-scope-5-internals@npm:5.1.1-v1": version: 5.1.1-v1 resolution: "@nicolo-ribaudo/eslint-scope-5-internals@npm:5.1.1-v1" @@ -8570,6 +8586,13 @@ __metadata: languageName: node linkType: hard +"@realm/common@npm:^0.1.4": + version: 0.1.4 + resolution: "@realm/common@npm:0.1.4" + checksum: b051dc928e3d9f3677723939379232e2ceba4f76b231bc26e449e5ed728ac82f9e99e391c52fe09823b0539d22fe5552465bbcbff2df95e8c18e5993aabaf983 + languageName: node + linkType: hard + "@remix-run/router@npm:1.11.0": version: 1.11.0 resolution: "@remix-run/router@npm:1.11.0" @@ -16716,6 +16739,15 @@ __metadata: languageName: node linkType: hard +"bson@npm:^4.5.4": + version: 4.7.2 + resolution: "bson@npm:4.7.2" + dependencies: + buffer: ^5.6.0 + checksum: f357d12c5679c8eb029a62e410ad40fb862b7b91f0fc12a3399fb3668e14aecaa63205ffeeee48735a01d393171743607dcd527eb8c058b6f2bd294079ee4125 + languageName: node + linkType: hard + "btoa@npm:^1.2.1": version: 1.2.1 resolution: "btoa@npm:1.2.1" @@ -19299,7 +19331,7 @@ __metadata: languageName: node linkType: hard -"detect-browser@npm:5.3.0, detect-browser@npm:^5.1.0, detect-browser@npm:^5.3.0": +"detect-browser@npm:5.3.0, detect-browser@npm:^5.1.0, detect-browser@npm:^5.2.1, detect-browser@npm:^5.3.0": version: 5.3.0 resolution: "detect-browser@npm:5.3.0" checksum: dd6e08d55da1d9e0f22510ac79872078ae03d9dfa13c5e66c96baedc1c86567345a88f96949161f6be8f3e0fafa93bf179bdb1cd311b14f5f163112fcc70ab49 @@ -35230,6 +35262,25 @@ __metadata: languageName: node linkType: hard +"realm-web@npm:^2.0.0": + version: 2.0.0 + resolution: "realm-web@npm:2.0.0" + dependencies: + "@realm/common": ^0.1.4 + abort-controller: ^3.0.0 + bson: ^4.5.4 + detect-browser: ^5.2.1 + js-base64: ^3.7.2 + node-fetch: ^2.6.0 + dependenciesMeta: + abort-controller: + optional: true + node-fetch: + optional: true + checksum: a4266502ad6fc63d068e8a05dac32522aa86b0f3f55474e769a286d2ec018cd557f5db9642d35c039f87ac2eea1aad54d32ecd27b6a59078f78aed58f445fbb3 + languageName: node + linkType: hard + "recast@npm:^0.21.0": version: 0.21.5 resolution: "recast@npm:0.21.5"