From 64d15436ed7effa5b8318f9f661927f0eb15d511 Mon Sep 17 00:00:00 2001 From: Makoto Inoue <2630+makoto@users.noreply.github.com> Date: Wed, 2 Jun 2021 13:10:37 +0100 Subject: [PATCH] Display snapshot and POAP + ENS support (#411) * Add snapshot activity * Add getDateFromUnix * Tidy up profile page * Add support for POAP badges * Show Snapshot avatar and fix TwitterAvatar * Display spaces on event page * Add alt title * Display ENS reverse record * Support ENS forward lookup * Change prefix --- package.json | 1 + src/api/rootResolver.js | 44 +++- src/components/Header/WalletButton.js | 5 +- src/components/Links/AddressLink.js | 27 +++ .../SingleEvent/EventParticipants.js | 70 ++++++- src/components/SingleEvent/Participant.js | 25 ++- src/components/Typography/Basic.js | 6 + src/components/UserProfile/UserProfile.js | 198 +++++++++++++----- src/graphql/queries.js | 34 +++ src/routes/UserProfile.js | 24 ++- src/utils/dates.js | 4 + yarn.lock | 18 ++ 12 files changed, 384 insertions(+), 72 deletions(-) create mode 100644 src/components/Links/AddressLink.js diff --git a/package.json b/package.json index e5d673f2..b87ac805 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "dependencies": { "@emotion/core": "^10.0.27", "@emotion/styled": "^10.0.27", + "@ensdomains/ens-contracts": "^0.0.3", "@wearekickback/contracts": "npm:@wearekickback/contracts-integration@1.4.5", "@wearekickback/shared": "^1.14.1", "apollo-cache-inmemory": "^1.2.8", diff --git a/src/api/rootResolver.js b/src/api/rootResolver.js index 0a99e9b3..35de5129 100644 --- a/src/api/rootResolver.js +++ b/src/api/rootResolver.js @@ -6,7 +6,8 @@ import getWeb3, { getAccount, getEvents, getDeployerAddress, - isLocalEndpoint + isLocalEndpoint, + getWeb3ForNetwork } from './web3' import singleEventResolvers, { defaults as singleEventDefaults @@ -16,6 +17,24 @@ import tokenResolvers, { defaults as tokenDefaults } from './resolvers/tokenResolvers' +let reverseAddress = '0x3671aE578E63FdF66ad4F3E12CC0c0d71Ac7510C' +let reverseAbi = [ + { + inputs: [{ internalType: 'contract ENS', name: '_ens', type: 'address' }], + stateMutability: 'nonpayable', + type: 'constructor' + }, + { + inputs: [ + { internalType: 'address[]', name: 'addresses', type: 'address[]' } + ], + name: 'getNames', + outputs: [{ internalType: 'string[]', name: 'r', type: 'string[]' }], + stateMutability: 'view', + type: 'function' + } +] + const deployerAbi = Deployer.abi const rootDefaults = { @@ -55,6 +74,29 @@ const resolvers = { address: event.args.deployedAddress, __typename: event.event })) + }, + async poapBadges(_, { userAddress }) { + let response = await fetch( + `https://api.poap.xyz/actions/scan/${userAddress}` + ) + return response.json() + }, + async getEnsName(_, { userAddress }) { + const web3 = await getWeb3ForNetwork('1') + const contract = new web3.eth.Contract(reverseAbi, reverseAddress).methods + + const name = await contract.getNames([userAddress]).call() + return { + name + } + }, + async getEnsAddress(_, { name }) { + const web3 = await getWeb3ForNetwork('1') + const address = await web3.eth.ens.getAddress(name) + + return { + address + } } }, diff --git a/src/components/Header/WalletButton.js b/src/components/Header/WalletButton.js index 67f2549b..53e6e6e1 100644 --- a/src/components/Header/WalletButton.js +++ b/src/components/Header/WalletButton.js @@ -7,6 +7,7 @@ import { GlobalConsumer } from '../../GlobalState' import Button from '../Forms/Button' import UserProfileButton from './UserProfileButton' import EtherScanLink from '../Links/EtherScanLink' +import AddressLink from '../Links/AddressLink' import c from '../../colours' const WalletWrapper = styled('div')` @@ -78,9 +79,7 @@ function WalletButton() { {userAddress && ( - - {userAddress.slice(0, 6)}...{userAddress.slice(-4)} - + )} {loggedIn && userProfile && ( diff --git a/src/components/Links/AddressLink.js b/src/components/Links/AddressLink.js new file mode 100644 index 00000000..c2bd0664 --- /dev/null +++ b/src/components/Links/AddressLink.js @@ -0,0 +1,27 @@ +import React from 'react' +import styled from '@emotion/styled' +import { ENS_NAME_QUERY } from '../../graphql/queries' +import { useQuery } from 'react-apollo' +import EtherScanLink from '../Links/EtherScanLink' + +const AddressLink = ({ userAddress, prefix = '' }) => { + const { data: ensData } = useQuery(ENS_NAME_QUERY, { + variables: { userAddress } + }) + + const ensName = + ensData && + ensData.getEnsName && + ensData.getEnsName.name && + ensData.getEnsName.name[0] + + return ( + + {ensName + ? `${prefix}${ensName}` + : `${prefix}${userAddress.slice(0, 6)}...${userAddress.slice(-4)}`} + + ) +} + +export default AddressLink diff --git a/src/components/SingleEvent/EventParticipants.js b/src/components/SingleEvent/EventParticipants.js index 49094722..8da52bd8 100644 --- a/src/components/SingleEvent/EventParticipants.js +++ b/src/components/SingleEvent/EventParticipants.js @@ -7,8 +7,22 @@ import DefaultEventFilters from './EventFilters' import { H3 } from '../Typography/Basic' import { sortParticipants, filterParticipants } from '../../utils/parties' -import { GET_CONTRIBUTIONS_BY_PARTY } from '../../graphql/queries' +import { + GET_CONTRIBUTIONS_BY_PARTY, + SNAPSHOT_VOTES_SUBGRAPH_QUERY +} from '../../graphql/queries' import SafeQuery from '../SafeQuery' +import { useQuery } from 'react-apollo' +import { ApolloClient } from 'apollo-client' +import { InMemoryCache } from 'apollo-cache-inmemory' +import { HttpLink } from 'apollo-link-http' +import _ from 'lodash' + +const cache = new InMemoryCache() +const link = new HttpLink({ + uri: 'https://hub.snapshot.page/graphql' +}) +const graphClient = new ApolloClient({ cache, link }) const EventParticipantsContainer = styled('div')` display: grid; @@ -53,6 +67,10 @@ const EventParticipants = props => { const handleSearch = search => { setSearch((search || '').toLowerCase()) } + const { data: snapshotData } = useQuery(SNAPSHOT_VOTES_SUBGRAPH_QUERY, { + variables: { userAddresses: participants.map(p => p.user.address) }, + client: graphClient + }) return ( { participants .sort(sortParticipants) .filter(filterParticipants(selectedFilter, search)) - .map(participant => ( - - )) + .map(participant => { + let votes = + snapshotData && + snapshotData.votes + .filter(v => { + return ( + v.voter.toLowerCase() == + participant.user.address.toLowerCase() + ) + }) + .map(v => { + return { + id: v.space.id, + avatar: v.space.avatar, + created: v.created + } + }) + let spaces = {} + votes && + votes.map(v => { + if (!spaces[v.id] || v.created > spaces[v.id].created) { + spaces[v.id] = v + } + }) + let filteredSpaces = _.sortBy(Object.values(spaces), [ + function(o) { + return o.created + } + ]).reverse() + + return ( + + ) + }) ) : ( No one is attending. )} diff --git a/src/components/SingleEvent/Participant.js b/src/components/SingleEvent/Participant.js index 90c4047d..080440d5 100644 --- a/src/components/SingleEvent/Participant.js +++ b/src/components/SingleEvent/Participant.js @@ -58,7 +58,29 @@ const TwitterAvatar = styled(DefaultTwitterAvatar)` margin-bottom: 5px; ` -function Participant({ participant, party, amAdmin, decimals, contributions }) { +const OrgAvatarImg = styled(`img`)` + width: 15px; + margin: 2px, 5px; +` + +const OrgAvatars = function({ spaces }) { + return ( +
+ {spaces.map(s => ( + + ))} +
+ ) +} + +function Participant({ + participant, + party, + amAdmin, + decimals, + contributions, + spaces +}) { const { user, status } = participant const { deposit, ended } = party @@ -80,6 +102,7 @@ function Participant({ participant, party, amAdmin, decimals, contributions }) { {user.username} + {ended ? ( attended ? ( contribution ? ( diff --git a/src/components/Typography/Basic.js b/src/components/Typography/Basic.js index 67a5a9aa..01be4181 100644 --- a/src/components/Typography/Basic.js +++ b/src/components/Typography/Basic.js @@ -18,6 +18,12 @@ export const H3 = styled('h3')` margin-top: 0; ` +export const H4 = styled('h4')` + font-size: 16px; + font-family: Muli; + margin-top: 0; +` + export const P = styled('p')` font-family: Muli; font-weight: 400; diff --git a/src/components/UserProfile/UserProfile.js b/src/components/UserProfile/UserProfile.js index 9ff51311..1f69cec3 100644 --- a/src/components/UserProfile/UserProfile.js +++ b/src/components/UserProfile/UserProfile.js @@ -2,7 +2,7 @@ import React, { useContext } from 'react' import styled from '@emotion/styled' import { getSocialId } from '@wearekickback/shared' import EventList from './EventList' -import { H2, H3 } from '../Typography/Basic' +import { H2, H3, H4 } from '../Typography/Basic' import mq from '../../mediaQuery' import { EDIT_PROFILE } from '../../modals' import GlobalContext from '../../GlobalState' @@ -10,10 +10,32 @@ import Button from '../Forms/Button' import DefaultTwitterAvatar from '../User/TwitterAvatar' import { depositValue } from '../Utils/DepositValue' import { Link } from 'react-router-dom' +import { + SNAPSHOT_VOTES_SUBGRAPH_QUERY, + POAP_BADGES_QUERY +} from '../../graphql/queries' +import { ApolloClient } from 'apollo-client' +import { InMemoryCache } from 'apollo-cache-inmemory' +import { HttpLink } from 'apollo-link-http' +import { useQuery } from 'react-apollo' +import _ from 'lodash' +import { getDateFromUnix } from '../../utils/dates' +import AddressLink from '../Links/AddressLink' + +const cache = new InMemoryCache() +const link = new HttpLink({ + uri: 'https://hub.snapshot.page/graphql' +}) +const graphClient = new ApolloClient({ cache, link }) + +const EventAttendedContainer = styled('div')` + margin-bottom: 10px; +` + const EventLink = styled(Link)`` const ContributionList = styled('ul')` - list-style: none; + margin-left: 2em; ` const UserProfileWrapper = styled('div')` @@ -41,16 +63,13 @@ const AvatarWrapper = styled('div')` ` const TwitterAvatar = styled(DefaultTwitterAvatar)` - width: 150px; - height: 150px; + width: 50px; + height: 50px; ` const Events = styled('div')` display: flex; flex-direction: column; - ${mq.medium` - flex-direction: row; - `} ` const EventType = styled('div')` @@ -82,12 +101,41 @@ const WalletButton = styled(Button)` width: 100%; ` +const PoapAvatar = styled('span')` + margin: 0 5px; +` + +const TinyAvatarImg = styled('img')` + margin-right: 5px; + width: 15px; +` + export default function UserProfile({ profile: p }) { const twitter = getSocialId(p.social, 'twitter') const { showModal, loggedIn, userProfile, signOut, wallet } = useContext( GlobalContext ) let walletLink + const { data: snapshotData } = useQuery(SNAPSHOT_VOTES_SUBGRAPH_QUERY, { + variables: { userAddresses: [p.address] }, + client: graphClient + }) + const { data: poapData } = useQuery(POAP_BADGES_QUERY, { + variables: { userAddress: p.address } + }) + + p.eventsAttended.map(p => (p.isAttended = true)) + p.eventsHosted.map(p => (p.isHosted = true)) + + const merged = _.merge( + _.keyBy(p.eventsAttended, 'name'), + _.keyBy(p.eventsHosted, 'name') + ) + let sorted = _.sortBy(Object.values(merged), [ + function(o) { + return o.createdAt + } + ]).reverse() if (wallet) { walletLink = wallet.url } @@ -103,6 +151,7 @@ export default function UserProfile({ profile: p }) { {twitter && ( Twitter: {twitter} )} + {loggedIn && userProfile && userProfile.username === p.username && ( {walletLink && ( @@ -131,55 +180,94 @@ export default function UserProfile({ profile: p }) { -

Events Attended ({p.eventsAttended.length})

- -
- -

Events Hosted ({p.eventsHosted.length})

- -
- -

Events Contributed ({p.eventsContributed.length})

- - {p.eventsContributed.map(t => { - return ( -
  • - Contributed {depositValue(t.amount, t.decimals, 3)}{' '} - {t.symbol} to{' '} - - {t.recipientUsername} - {' '} - at{' '} - - {t.name} - -
  • - ) - })} -
    -
    - -

    - Contribution received ({p.eventsContributionReceived.length}) -

    - - {p.eventsContributionReceived.map(t => { - return ( -
  • - Received {depositValue(t.amount, t.decimals, 3)} {t.symbol}{' '} - from{' '} - - {t.senderUsername} - {' '} - at{' '} - - {t.name} - -
  • - ) - })} -
    +

    Kickback Event activites

    + + {sorted.map(event => { + let contributed = p.eventsContributed.filter( + p => p.name === event.name + ) + let contributionReceived = p.eventsContributionReceived.filter( + p => p.name === event.name + ) + return ( + + + {event.name} + + {event.isHosted && '(Host)'} + {(contributed.length > 0 || + contributionReceived.length > 0) && ( + + {contributed.map(t => { + return ( +
  • + Contributed {depositValue(t.amount, t.decimals, 3)}{' '} + {t.symbol} to{' '} + + {t.recipientUsername} + {' '} +
  • + ) + })} + {contributionReceived.map(t => { + return ( +
  • + Received {depositValue(t.amount, t.decimals, 3)}{' '} + {t.symbol} from{' '} + + {t.senderUsername} + {' '} +
  • + ) + })} +
    + )} +
    + ) + })}
    + {snapshotData && snapshotData.votes.length > 0 && ( + +

    Other activities

    +

    POAP

    + {poapData && + poapData.poapBadges && + poapData.poapBadges.slice(0, 10).map(p => { + return ( + + + {p.event.name} + + + ) + })} +

    Snapshot

    + + {snapshotData.votes.map(v => { + return ( +
  • + + {v.space.avatar && ( + + )} + Voted {v.choice} on {v.space.id} + {' '} + at {getDateFromUnix(v.created)} +
  • + ) + })} +
    +
    + )}
    diff --git a/src/graphql/queries.js b/src/graphql/queries.js index f874f2de..2787fcea 100644 --- a/src/graphql/queries.js +++ b/src/graphql/queries.js @@ -178,3 +178,37 @@ export const GET_MAINNET_TOKEN_BALANCE = gql` ) @client } ` + +export const SNAPSHOT_VOTES_SUBGRAPH_QUERY = gql` + query Votes($userAddresses: [String]) { + votes(first: 1000, where: { voter_in: $userAddresses }) { + id + voter + created + proposal + choice + space { + id + avatar + } + } + } +` + +export const POAP_BADGES_QUERY = gql` + query poapBadges($userAddress: String!) { + poapBadges: poapBadges(userAddress: $userAddress) @client + } +` + +export const ENS_NAME_QUERY = gql` + query getEnsName($userAddress: String!) { + getEnsName: getEnsName(userAddress: $userAddress) @client + } +` + +export const ENS_ADDRESS_QUERY = gql` + query getEnsAddress($name: String!) { + getEnsAddress: getEnsAddress(name: $name) @client + } +` diff --git a/src/routes/UserProfile.js b/src/routes/UserProfile.js index aa7f1c13..45172f5d 100644 --- a/src/routes/UserProfile.js +++ b/src/routes/UserProfile.js @@ -3,12 +3,32 @@ import { Query } from 'react-apollo' import Loader from '../components/Loader' import UserProfile from '../components/UserProfile' -import { USER_PROFILE_DETAILED_QUERY } from '../graphql/queries' +import { + USER_PROFILE_DETAILED_QUERY, + ENS_ADDRESS_QUERY +} from '../graphql/queries' +import { useQuery } from 'react-apollo' export default function UserProfileData(props) { const { username } = props.match.params + const isAddress = !!(username.length === 42 && username.match(/^0x/)) + const isEns = username.match(/\./) + const { data: ensData } = useQuery(ENS_ADDRESS_QUERY, { + variables: { name: username }, + skip: !isEns + }) + let variables + if (isAddress || isEns) { + if (ensData && ensData.getEnsAddress && ensData.getEnsAddress.address) { + variables = { address: ensData.getEnsAddress.address } + } else { + variables = { address: username } + } + } else { + variables = { username } + } return ( - + {({ data, loading, error }) => { if (loading) return if (error) { diff --git a/src/utils/dates.js b/src/utils/dates.js index af405d16..81e35165 100644 --- a/src/utils/dates.js +++ b/src/utils/dates.js @@ -44,3 +44,7 @@ export const getUtcDateFromTimezone = (dateString, timezone = '') => { .format('Z') return moment.utc(timezoneDateString).format('YYYYMMDDTHHmmssZ') } + +export const getDateFromUnix = unixtimesamp => { + return moment(new Date(unixtimesamp * 1000)).format('MMM Do, YYYY') +} diff --git a/yarn.lock b/yarn.lock index 2a767c7c..b6a70dd0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1571,6 +1571,19 @@ resolved "https://registry.yarnpkg.com/@emotion/weak-memoize/-/weak-memoize-0.2.5.tgz#8eed982e2ee6f7f4e44c253e12962980791efd46" integrity sha512-6U71C2Wp7r5XtFtQzYrW5iKFT67OixrSxjI4MptCHzdSVlgabczzqLe0ZSgnub/5Kp4hSbpDB1tMytZY9pwxxA== +"@ensdomains/buffer@^0.0.10": + version "0.0.10" + resolved "https://registry.yarnpkg.com/@ensdomains/buffer/-/buffer-0.0.10.tgz#3b9f8b6a34c6160ae8a8cb8f0f033aa35c1a9970" + integrity sha512-EOFqiWnN36EyyBAgHFTsabFcFICUALt41SiDm/4pAw4V36R4lD4wHcnZcqCYki9m1fMaeWGHrdqxmrMa8iiSTQ== + +"@ensdomains/ens-contracts@^0.0.3": + version "0.0.3" + resolved "https://registry.yarnpkg.com/@ensdomains/ens-contracts/-/ens-contracts-0.0.3.tgz#7023a3ad8e74431fa64c550f63c5730d9cb64303" + integrity sha512-da67JjAFjl8gLDDAqYQs5PSvth9usBcD7clXiXpfvJTMJnZnR+c/cG6xrkVgL4qEP7jmI+iEoj0y29qFtupy2w== + dependencies: + "@ensdomains/buffer" "^0.0.10" + "@openzeppelin/contracts" "^4.1.0" + "@ethersproject/abi@5.0.7": version "5.0.7" resolved "https://registry.yarnpkg.com/@ethersproject/abi/-/abi-5.0.7.tgz#79e52452bd3ca2956d0e1c964207a58ad1a0ee7b" @@ -2397,6 +2410,11 @@ "@nodelib/fs.scandir" "2.1.4" fastq "^1.6.0" +"@openzeppelin/contracts@^4.1.0": + version "4.1.0" + resolved "https://registry.yarnpkg.com/@openzeppelin/contracts/-/contracts-4.1.0.tgz#baec89a7f5f73e3d8ea582a78f1980134b605375" + integrity sha512-TihZitscnaHNcZgXGj9zDLDyCqjziytB4tMCwXq0XimfWkAjBYyk5/pOsDbbwcavhlc79HhpTEpQcrMnPVa1mw== + "@pedrouid/environment@^1.0.1": version "1.0.1" resolved "https://registry.yarnpkg.com/@pedrouid/environment/-/environment-1.0.1.tgz#858f0f8a057340e0b250398b75ead77d6f4342ec"