TC-98 | Add ProposalComments component with tab filters

This commit is contained in:
Andrey Pastukhov 2022-07-29 14:57:40 +10:00
parent 3cef4c4d5b
commit 6e01a677ae
No known key found for this signature in database
GPG Key ID: A841818F95ED6301
14 changed files with 876 additions and 336 deletions

View File

@ -0,0 +1,9 @@
const routerScrollBehavior = (to, from, savedPosition) => {
if (to.name === 'governance-id') {
return { x: 0, y: 0 }
}
return savedPosition || { x: 0, y: 0 }
}
export default routerScrollBehavior

View File

@ -52,11 +52,25 @@
</div> </div>
</div> </div>
<b-button v-if="support" type="is-primary" icon-left="check" outlined @click="onCastVote(true)"> <b-button
v-if="support"
:disabled="!isValid"
type="is-primary"
icon-left="check"
outlined
@click="onCastVote(true)"
>
{{ $t('for') }} {{ $t('for') }}
</b-button> </b-button>
<b-button v-else type="is-danger" icon-left="close" outlined @click="onCastVote(false)"> <b-button
v-else
:disabled="!isValid"
type="is-danger"
icon-left="close"
outlined
@click="onCastVote(false)"
>
{{ $t('against') }} {{ $t('against') }}
</b-button> </b-button>
</div> </div>
@ -88,6 +102,11 @@ export default {
message: '' message: ''
} }
}), }),
computed: {
isValid() {
return this.validate()
}
},
methods: { methods: {
validate() { validate() {
const { form, fields, support } = this const { form, fields, support } = this
@ -100,9 +119,7 @@ export default {
return fields.contact && fields.message return fields.contact && fields.message
}, },
onCastVote() { onCastVote() {
const isValid = this.validate() if (this.isValid) {
if (isValid) {
this.$emit('castVote', this.form) this.$emit('castVote', this.form)
this.$emit('close') this.$emit('close')
} }

View File

@ -4,158 +4,159 @@
<div class="column is-7-tablet is-8-desktop"> <div class="column is-7-tablet is-8-desktop">
<h1 class="title">{{ data.title }}</h1> <h1 class="title">{{ data.title }}</h1>
<div class="description"> <div class="description">
<p>{{ data.description }}</p> <p class="proposal--description">{{ data.description }}</p>
</div>
<div>
<ProposalCommentsSkeleton
v-if="isFetchingProposalComments"
:size="proposalComments.length ? 1 : 3"
/>
<ProposalComment v-for="item in proposalComments" :key="item.id" v-bind="item" />
</div> </div>
<ProposalComments
v-if="isEnabledGovernance && quorumPercent"
:proposal="data"
:is-initialized="isInitialized"
class="proposal--comments"
/>
</div> </div>
<div class="column is-5-tablet is-4-desktop"> <div class="column is-5-tablet is-4-desktop">
<div v-if="data.status === 'active'" class="proposal-block"> <div class="proposal--blocks">
<div class="title">{{ $t('castYourVote') }}</div> <div v-if="data.status === 'active'" class="proposal-block">
<b-tooltip <div class="title">{{ $t('castYourVote') }}</div>
class="fit-content"
:label="tooltipMessage"
position="is-top"
:active="readyForAction"
multilined
>
<div class="buttons buttons__halfwidth">
<b-button
:disabled="readyForAction"
type="is-primary"
:icon-left="isFetchingBalances || isSaveProposal ? '' : 'check'"
outlined
:loading="isFetchingBalances || isSaveProposal"
@click="onCastVote(true)"
>{{ $t('for') }}</b-button
>
<b-button
:disabled="readyForAction"
type="is-danger"
:icon-left="isFetchingBalances || isSaveProposal ? '' : 'close'"
outlined
:loading="isFetchingBalances || isSaveProposal"
@click="onCastVote(false)"
>{{ $t('against') }}</b-button
>
</div>
</b-tooltip>
<i18n
v-if="voterReceipts[data.id] && voterReceipts[data.id].hasVoted"
tag="div"
path="yourCurrentVote"
>
<template v-slot:vote>
<span
:class="{
'has-text-primary': voterReceipts[data.id].support,
'has-text-danger': !voterReceipts[data.id].support
}"
>{{ $n(fromWeiToTorn(voterReceipts[data.id].balance)) }} TORN</span
>
</template>
</i18n>
</div>
<div v-else-if="data.status === 'awaitingExecution'" class="proposal-block">
<div class="title">{{ $t('executeProposal') }}</div>
<b-tooltip
class="fit-content"
:label="$t('connectYourWalletFirst')"
position="is-top"
:active="!ethAccount"
multilined
>
<b-button
type="is-primary"
icon-left="check"
outlined
:disabled="!ethAccount"
expanded
@click="onExecute"
>{{ $t('execute') }}</b-button
>
</b-tooltip>
</div>
<div class="proposal-block">
<div class="title">{{ $t('currentResults') }}</div>
<div class="label">
{{ $t('for') }}
<span class="percent"
><number-format :value="data.results.for" /> TORN / {{ calculatePercent('for') }}%</span
>
</div>
<b-progress :value="calculatePercent('for')" type="is-primary"></b-progress>
<div class="label">
{{ $t('against') }}
<span class="percent"
><number-format :value="data.results.against" class="value" /> TORN /
{{ calculatePercent('against') }}%</span
>
</div>
<b-progress :value="calculatePercent('against')" type="is-danger"></b-progress>
<div class="label">
{{ $t('quorum') }}
<b-tooltip <b-tooltip
:label=" class="fit-content"
$t('quorumTooltip', { :label="tooltipMessage"
days: $tc('dayPlural', votingPeriod),
votes: $n(quorumVotes, 'compact')
})
"
size="is-medium"
position="is-top" position="is-top"
:active="readyForAction"
multilined multilined
> >
<button class="button is-primary has-icon"> <div class="buttons buttons__halfwidth">
<span class="icon icon-info"></span> <b-button
</button> :disabled="readyForAction"
type="is-primary"
:icon-left="isFetchingBalances || isCastingVote ? '' : 'check'"
outlined
:loading="isFetchingBalances || isCastingVote"
@click="onCastVote(true)"
>{{ $t('for') }}</b-button
>
<b-button
:disabled="readyForAction"
type="is-danger"
:icon-left="isFetchingBalances || isCastingVote ? '' : 'close'"
outlined
:loading="isFetchingBalances || isCastingVote"
@click="onCastVote(false)"
>{{ $t('against') }}</b-button
>
</div>
</b-tooltip> </b-tooltip>
<span class="percent" <i18n
><number-format :value="isQuorumCompleted ? quorumVotes : quorumResult" class="value" /> TORN / v-if="voterReceipts[data.id] && voterReceipts[data.id].hasVoted"
{{ quorumPercent }}%</span tag="div"
path="yourCurrentVote"
> >
<template v-slot:vote>
<span
:class="{
'has-text-primary': voterReceipts[data.id].support,
'has-text-danger': !voterReceipts[data.id].support
}"
>{{ $n(fromWeiToTorn(voterReceipts[data.id].balance)) }} TORN</span
>
</template>
</i18n>
</div> </div>
<b-progress :value="quorumPercent" type="is-violet"></b-progress> <div v-else-if="data.status === 'awaitingExecution'" class="proposal-block">
</div> <div class="title">{{ $t('executeProposal') }}</div>
<div class="proposal-block"> <b-tooltip
<div class="title">{{ $t('information') }}</div> class="fit-content"
<div class="columns is-multiline is-small" :class="{ 'has-countdown': countdown }"> :label="$t('connectYourWalletFirst')"
<div class="column is-full-small"> position="is-top"
<strong>{{ $t('proposalAddress') }}</strong> :active="!ethAccount"
<div class="value"> multilined
<a :href="contractUrl" class="address" target="_blank" rel="noopener noreferrer"> >
{{ data.target }} <b-button
</a> type="is-primary"
icon-left="check"
outlined
:disabled="!ethAccount"
expanded
@click="onExecute"
>{{ $t('execute') }}</b-button
>
</b-tooltip>
</div>
<div class="proposal-block">
<div class="title">{{ $t('currentResults') }}</div>
<div class="label">
{{ $t('for') }}
<span class="percent"
><number-format :value="data.results.for" /> TORN / {{ calculatePercent('for') }}%</span
>
</div>
<b-progress :value="calculatePercent('for')" type="is-primary"></b-progress>
<div class="label">
{{ $t('against') }}
<span class="percent"
><number-format :value="data.results.against" class="value" /> TORN /
{{ calculatePercent('against') }}%</span
>
</div>
<b-progress :value="calculatePercent('against')" type="is-danger"></b-progress>
<div class="label">
{{ $t('quorum') }}
<b-tooltip
:label="
$t('quorumTooltip', {
days: $tc('dayPlural', votingPeriod),
votes: $n(quorumVotes, 'compact')
})
"
size="is-medium"
position="is-top"
multilined
>
<button class="button is-primary has-icon">
<span class="icon icon-info"></span>
</button>
</b-tooltip>
<span class="percent"
><number-format :value="isQuorumCompleted ? quorumVotes : quorumResult" class="value" /> TORN
/ {{ quorumPercent }}%</span
>
</div>
<b-progress :value="quorumPercent" type="is-violet"></b-progress>
</div>
<div class="proposal-block">
<div class="title">{{ $t('information') }}</div>
<div class="columns is-multiline is-small" :class="{ 'has-countdown': countdown }">
<div class="column is-full-small">
<strong>{{ $t('proposalAddress') }}</strong>
<div class="value">
<a :href="contractUrl" class="address" target="_blank" rel="noopener noreferrer">
{{ data.target }}
</a>
</div>
</div> </div>
</div> <div class="column is-half-small">
<div class="column is-half-small"> <strong>{{ $t('id') }}</strong>
<strong>{{ $t('id') }}</strong> <div class="value">{{ data.id }}</div>
<div class="value">{{ data.id }}</div>
</div>
<div class="column is-half-small">
<strong>{{ $t('status') }}</strong>
<div class="value">
<b-tag :type="getStatusType(data.status)">{{ $t(data.status) }}</b-tag>
</div> </div>
</div> <div class="column is-half-small">
<div class="column is-half-small"> <strong>{{ $t('status') }}</strong>
<strong>{{ $t('startDate') }}</strong> <div class="value">
<div class="value">{{ $moment.unix(data.startTime).format('llll') }}</div> <b-tag :type="getStatusType(data.status)">{{ $t(data.status) }}</b-tag>
</div> </div>
<div class="column is-half-small"> </div>
<strong>{{ $t('endDate') }}</strong> <div class="column is-half-small">
<div class="value">{{ $moment.unix(data.endTime).format('llll') }}</div> <strong>{{ $t('startDate') }}</strong>
</div> <div class="value">{{ $moment.unix(data.startTime).format('llll') }}</div>
<div v-if="countdown" class="column is-full-small"> </div>
<strong>{{ $t(timerLabel) }}</strong> <div class="column is-half-small">
<div class="value"> <strong>{{ $t('endDate') }}</strong>
{{ countdown }} <div class="value">{{ $moment.unix(data.endTime).format('llll') }}</div>
</div>
<div v-if="countdown" class="column is-full-small">
<strong>{{ $t(timerLabel) }}</strong>
<div class="value">
{{ countdown }}
</div>
</div> </div>
</div> </div>
</div> </div>
@ -168,8 +169,7 @@
<script> <script>
import { mapState, mapActions, mapGetters } from 'vuex' import { mapState, mapActions, mapGetters } from 'vuex'
import quorum from './mixins/quorum' import quorum from './mixins/quorum'
import ProposalCommentsSkeleton from './ProposalCommentsSkeleton.vue' import ProposalComments from './ProposalComments.vue'
import ProposalComment from './ProposalComment.vue'
import NumberFormat from '@/components/NumberFormat' import NumberFormat from '@/components/NumberFormat'
import ProposalCommentFormModal from '@/components/ProposalCommentFormModal.vue' import ProposalCommentFormModal from '@/components/ProposalCommentFormModal.vue'
@ -177,8 +177,7 @@ const { toBN, fromWei, toWei } = require('web3-utils')
export default { export default {
components: { components: {
ProposalCommentsSkeleton, ProposalComments,
ProposalComment,
NumberFormat NumberFormat
}, },
mixins: [quorum], mixins: [quorum],
@ -196,7 +195,7 @@ export default {
} }
}, },
computed: { computed: {
...mapState('governance/gov', ['proposals', 'voterReceipts', 'proposalComments', 'isSaveProposal']), ...mapState('governance/gov', ['proposals', 'voterReceipts', 'isCastingVote']),
...mapState('metamask', ['ethAccount', 'isInitialized']), ...mapState('metamask', ['ethAccount', 'isInitialized']),
...mapGetters('txHashKeeper', ['addressExplorerUrl']), ...mapGetters('txHashKeeper', ['addressExplorerUrl']),
...mapGetters('metamask', ['networkConfig']), ...mapGetters('metamask', ['networkConfig']),
@ -205,7 +204,6 @@ export default {
'constants', 'constants',
'votingPeriod', 'votingPeriod',
'isFetchingBalances', 'isFetchingBalances',
'isFetchingProposalComments',
'isEnabledGovernance' 'isEnabledGovernance'
]), ]),
readyForAction() { readyForAction() {
@ -239,9 +237,7 @@ export default {
isInitialized: { isInitialized: {
handler(isInitialized) { handler(isInitialized) {
if (isInitialized && this.isEnabledGovernance) { if (isInitialized && this.isEnabledGovernance) {
const { id } = this.data this.fetchReceipt({ id: this.data.id })
this.fetchReceipt({ id })
this.fetchProposalComments(this.data)
} }
}, },
immediate: true immediate: true
@ -282,13 +278,7 @@ export default {
clearTimeout(this.timeId) clearTimeout(this.timeId)
}, },
methods: { methods: {
...mapActions('governance/gov', [ ...mapActions('governance/gov', ['castVote', 'executeProposal', 'fetchReceipt', 'fetchProposals']),
'castVote',
'executeProposal',
'fetchReceipt',
'fetchProposals',
'fetchProposalComments'
]),
getStatusType(status) { getStatusType(status) {
let statusType = '' let statusType = ''
switch (status) { switch (status) {
@ -393,3 +383,20 @@ export default {
} }
} }
</script> </script>
<style lang="scss" scoped>
.proposal {
&--description {
word-break: break-word;
}
&--comments {
margin-top: 2rem;
}
&--blocks {
position: sticky;
top: 1rem;
}
}
</style>

View File

@ -2,39 +2,71 @@
<div class="proposals-box"> <div class="proposals-box">
<div class="columns is-gapless"> <div class="columns is-gapless">
<div class="column proposals-box--tags"> <div class="column proposals-box--tags">
<div <div class="proposals-box--tag-item">
class="tag" <div class="tag proposals-box--id">
:class="{ <span><number-format :value="votes" /> TORN</span>
'proposals-box--revote': revote, </div>
'is-primary': support,
'is-danger': !support
}"
>
<span><number-format :value="votes" /> TORN</span>
</div> </div>
<b-tooltip v-if="delegator" :label="delegator" position="is-top"> <div class="proposals-box--tag-item">
<div class="tag proposals-box--id">{{ $t('delegated') }}</div> <b-tooltip
</b-tooltip> :label="ens.voter || voter"
position="is-top"
:multilined="ens.voter && ens.voter.length > 50"
>
<a
target="_blank"
:href="addressExplorerUrl(voter)"
rel="noopener noreferrer"
class="tag proposals-box--id is-link"
v-text="shortVoter"
/>
</b-tooltip>
</div>
<b-tooltip :label="voter" position="is-top"> <div v-if="delegator" class="proposals-box--tag-item">
<div class="tag proposals-box--id">{{ shortVoter }}</div> <b-tooltip
</b-tooltip> :label="ens.delegator || delegator"
</div> position="is-top"
<div class="column is-narrow proposals-box--date"> :multilined="ens.delegator && ens.delegator.length > 50"
<div class="date"> >
<span>{{ $t('date') }}:</span> {{ date }} <a
target="_blank"
:href="addressExplorerUrl(delegator)"
rel="noopener noreferrer"
class="tag proposals-box--id is-link"
v-text="$t('delegate')"
/>
</b-tooltip>
</div>
<div class="proposals-box--tag-item is-percentage">
<div class="tag proposals-box--id is-percentage">{{ percentage || '~0.1' }}%</div>
</div> </div>
</div> </div>
</div> </div>
<span v-if="contact" class="proposals-box--title">{{ contact }}</span> <div class="proposals-box--comment">
<div v-if="message" class="proposals-box--info" v-text="message" /> <b-icon
:icon="support ? 'check' : 'close'"
:type="support ? 'is-primary' : 'is-danger'"
class="proposals-box--status-icon"
/>
<span v-if="loading" class="proposals-box--skeleton">
<b-skeleton height="21" width="260" style="width: auto;" />
</span>
<template v-else>
<span v-if="contact" class="proposals-box--title">{{ contact }}</span>
<span v-if="message" class="proposals-box--info">{{ message }}</span>
<span v-if="!contact && !message">-</span>
</template>
</div>
</div> </div>
</template> </template>
<script> <script>
import { sliceAddress } from '@/utils' import { mapGetters } from 'vuex'
import { sliceAddress, sliceEnsName } from '@/utils'
import NumberFormat from '@/components/NumberFormat' import NumberFormat from '@/components/NumberFormat'
export default { export default {
@ -43,6 +75,10 @@ export default {
}, },
inheritAttrs: false, inheritAttrs: false,
props: { props: {
loading: {
type: Boolean,
required: true
},
contact: { contact: {
type: String, type: String,
required: true required: true
@ -55,10 +91,6 @@ export default {
type: Boolean, type: Boolean,
required: true required: true
}, },
timestamp: {
type: Number,
required: true
},
votes: { votes: {
type: String, type: String,
required: true required: true
@ -67,56 +99,105 @@ export default {
type: String, type: String,
required: true required: true
}, },
revote: { percentage: {
type: Boolean, type: Number,
required: true required: true
}, },
delegator: { delegator: {
type: String, type: String,
default: '' default: ''
},
ens: {
type: Object,
required: true,
validator: (props) => 'delegator' in props && 'voter' in props
} }
}, },
data: (vm) => ({ computed: {
shortVoter: sliceAddress(vm.voter), ...mapGetters('txHashKeeper', ['addressExplorerUrl']),
date: [vm.$moment.unix(vm.timestamp).format('l'), vm.$moment.unix(vm.timestamp).format('hh:mm')].join(' ')
}) shortVoter() {
return sliceEnsName(this.ens.voter || '') || sliceAddress(this.voter)
}
}
} }
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
$margin: 0.714rem;
.proposals-box { .proposals-box {
cursor: default; cursor: default;
.tag { .tag {
margin: 0;
width: 100%; width: 100%;
margin: 0;
&.is-link {
text-decoration: none;
background-color: #363636;
transition: background-color 0.15s ease-in-out;
&:hover {
background-color: rgba(#363636, 0.5);
}
}
&.is-percentage {
padding: 0;
margin: 0;
background: transparent;
text-align: right;
}
}
.columns {
margin-bottom: 0;
} }
&--tags { &--tags {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
grid-template-columns: repeat(auto-fill, minmax(100px, auto)); margin: calc(#{-$margin * 0.5}) !important;
display: grid;
grid-row-gap: 0.714rem;
grid-column-gap: 0.714rem;
} }
&--date { &--tag-item {
display: flex; margin: calc(#{$margin * 0.5});
align-items: center; width: auto;
} min-width: 110px;
&--title { @media screen and (max-width: 600px) {
display: flex; width: calc(50% - #{$margin});
}
& > * {
display: flex;
width: 100%;
}
&.is-percentage {
min-width: auto;
margin-left: auto;
}
} }
&--title, &--title,
&--info { &--info {
word-break: break-word; word-break: break-word;
display: inline;
} }
&--revote { &--status-icon {
text-decoration: line-through; vertical-align: middle;
margin-bottom: 0.2rem;
}
&--comment {
margin-top: 1.5rem;
}
&--skeleton {
display: inline-block;
} }
} }
</style> </style>

View File

@ -0,0 +1,91 @@
<template>
<div class="proposal-comments">
<div class="proposals-list--header proposal-comments--header">
<b-field class="field-tabs">
<template v-for="item in tabs">
<b-radio-button :key="item.id" v-model="currentTab" :native-value="item.id" type="is-primary">
<span>{{ $t(item.label) }}</span>
</b-radio-button>
</template>
</b-field>
</div>
<ProposalCommentsSkeleton v-if="isFetchingComments" :size="comments.length ? 1 : 3" />
<ProposalComment
v-for="item in commentsFiltered"
:key="item.id"
v-bind="item"
:loading="isFetchingMessages"
/>
</div>
</template>
<script>
import { mapActions, mapGetters, mapState } from 'vuex'
import ProposalCommentsSkeleton from './ProposalCommentsSkeleton.vue'
import ProposalComment from './ProposalComment.vue'
const TabTypes = {
all: 'all',
for: 'for',
against: 'against'
}
const TAB_LIST = [
{ id: TabTypes.all, label: 'all' },
{ id: TabTypes.for, label: 'for' },
{ id: TabTypes.against, label: 'against' }
]
export default {
components: {
ProposalCommentsSkeleton,
ProposalComment
},
inheritAttrs: false,
props: {
proposal: {
type: Object,
required: true
}
},
data: () => ({
tabs: TAB_LIST,
currentTab: TAB_LIST[0].id
}),
computed: {
...mapState('governance/proposal', ['isFetchingComments', 'isFetchingMessages']),
...mapGetters('governance/proposal', ['comments']),
commentsFiltered() {
const { comments } = this
switch (this.currentTab) {
case TabTypes.for:
return comments.filter((_) => _.support === true)
case TabTypes.against:
return comments.filter((_) => _.support === false)
case TabTypes.all:
default:
return comments
}
}
},
created() {
this.fetchComments(this.proposal)
},
methods: {
...mapActions('governance/proposal', ['fetchComments'])
}
}
</script>
<style lang="scss" scoped>
.proposal-comments {
&--header {
margin-bottom: 0.5rem;
}
}
</style>

View File

@ -2,12 +2,26 @@
<div> <div>
<div v-for="index in size" :key="index" class="proposals-box"> <div v-for="index in size" :key="index" class="proposals-box">
<div class="columns is-gapless"> <div class="columns is-gapless">
<div class="column is-8-tablet is-9-desktop"> <div class="column">
<div class="proposals-box--title"> <div class="proposals-box--title">
<b-skeleton height="21" width="210" /> <div class="proposals-box--skeleton">
<b-skeleton height="30" width="110" />
</div>
<div class="proposals-box--skeleton">
<b-skeleton height="30" width="110" />
</div>
<div class="proposals-box--skeleton is-percentage">
<b-skeleton height="30" width="36" />
</div>
</div> </div>
<div class="proposals-box--info"> <div class="proposals-box--comment">
<b-skeleton height="21" width="260" /> <div class="proposals-box--skeleton">
<b-skeleton height="21" width="21" />
</div>
<div class="proposals-box--skeleton">
<b-skeleton height="21" width="260" />
</div>
</div> </div>
</div> </div>
</div> </div>
@ -25,3 +39,27 @@ export default {
} }
} }
</script> </script>
<style lang="scss" scoped>
$margin: 0.714rem;
.proposals-box {
&--title {
width: 100%;
margin: 0;
display: inline-block;
}
&--skeleton {
display: inline-block;
&.is-percentage {
float: right;
}
}
&--comment {
margin-top: 1.5rem;
}
}
</style>

View File

@ -95,3 +95,5 @@ export const DUMMY_NONCE = '0x11111111111111111111111111111111111111111111111111
export const DUMMY_WITHDRAW_DATA = export const DUMMY_WITHDRAW_DATA =
'0x0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111' '0x0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111'
export const CHUNK_COUNT_PER_BATCH_REQUEST = 200

View File

@ -285,10 +285,10 @@
}, },
"proposalComment": { "proposalComment": {
"modal-title": "Title: Proposal #{id}", "modal-title": "Title: Proposal #{id}",
"modal-subtitle": "Please provide feedback about your decision. Why are you against of this proposal?", "modal-subtitle": "Please explain: Why are you for or against this proposal?",
"form-contact": "Contact", "form-contact": "Contact",
"form-contact-placeholder": "Enter contact (optional)", "form-contact-placeholder": "Enter contact (optional)",
"form-contact-tooltip": "Contact (optional) may be nickname in forum, email, telegram, twitter or others", "form-contact-tooltip": "Contact (optional) may be a nickname in forum, email, telegram, twitter or other",
"form-message": "Message", "form-message": "Message",
"form-message-placeholder": "Enter message", "form-message-placeholder": "Enter message",
"form-message-opt-placeholder": "Enter message (optional)", "form-message-opt-placeholder": "Enter message (optional)",

View File

@ -116,7 +116,7 @@ export default {
ensSubdomainKey: 'mainnet-tornado', ensSubdomainKey: 'mainnet-tornado',
pollInterval: 15, pollInterval: 15,
constants: { constants: {
GOVERNANCE_TORNADOCASH_BLOCK: 11474695, GOVERNANCE_BLOCK: 11474695,
NOTE_ACCOUNT_BLOCK: 11842486, NOTE_ACCOUNT_BLOCK: 11842486,
ENCRYPTED_NOTES_BLOCK: 14248730, ENCRYPTED_NOTES_BLOCK: 14248730,
MINING_BLOCK_TIME: 15 MINING_BLOCK_TIME: 15
@ -535,7 +535,7 @@ export default {
ensSubdomainKey: 'goerli-tornado', ensSubdomainKey: 'goerli-tornado',
pollInterval: 15, pollInterval: 15,
constants: { constants: {
GOVERNANCE_TORNADOCASH_BLOCK: 3945171, GOVERNANCE_BLOCK: 3945171,
NOTE_ACCOUNT_BLOCK: 4131375, NOTE_ACCOUNT_BLOCK: 4131375,
ENCRYPTED_NOTES_BLOCK: 4131375, ENCRYPTED_NOTES_BLOCK: 4131375,
MINING_BLOCK_TIME: 15 MINING_BLOCK_TIME: 15

View File

@ -9,6 +9,7 @@ export * from './events'
export { default as graph } from './graph' export { default as graph } from './graph'
export { default as schema } from './schema' export { default as schema } from './schema'
export { default as walletConnectConnector } from './walletConnect' export { default as walletConnectConnector } from './walletConnect'
export * from './lookupAddress'
// eslint-disable-next-line no-undef // eslint-disable-next-line no-undef
window.graph = graph window.graph = graph

114
services/lookupAddress.js Normal file
View File

@ -0,0 +1,114 @@
// from https://github.com/ChainSafe/web3.js/issues/2683#issuecomment-547348416
import namehash from 'eth-ens-namehash'
import { BigNumber, utils } from 'ethers'
import ABI from 'web3-eth-ens/lib/resources/ABI/Resolver'
import uniq from 'lodash/uniq'
import chunk from 'lodash/chunk'
import { CHUNK_COUNT_PER_BATCH_REQUEST } from '@/constants'
export const createBatchRequestCallback = (resolve, reject) => (error, data) => {
if (error) {
reject(error)
} else {
resolve(data)
}
}
const CACHE = {}
const createFetchNodeAddresses = (registryContract, batch) => async (address) => {
const addressLower = address.toLowerCase()
const node = addressLower.substr(2) + '.addr.reverse'
const nodeHash = namehash.hash(node)
let nodeAddress = null
if (!CACHE[addressLower]) {
try {
nodeAddress = await new Promise((resolve, reject) => {
const callback = createBatchRequestCallback(resolve, reject)
const requestData = registryContract.methods.resolver(nodeHash).call.request(callback)
batch.add(requestData)
})
if (+nodeAddress === 0) nodeAddress = null
} catch (error) {
console.error(`Error resolve ens for "${address}"`, error.message)
// do nothing
}
}
return {
addressLower,
address,
nodeHash,
nodeAddress
}
}
const createFetchEnsNames = (web3, batch, results) => async (data) => {
const { address, addressLower, nodeHash, nodeAddress } = data
if (!nodeAddress) return results
if (CACHE[addressLower]) {
results[address] = CACHE[addressLower]
return results
}
const nodeContract = new web3.eth.Contract(ABI, nodeAddress)
try {
const ensName = await new Promise((resolve, reject) => {
const callback = createBatchRequestCallback(resolve, reject)
const requestData = nodeContract.methods.name(nodeHash).call.request(callback)
batch.add(requestData)
})
const isZeroAddress =
ensName.trim().length && utils.isAddress(ensName) && BigNumber.from(ensName).isZero()
if (isZeroAddress) return results
CACHE[addressLower] = ensName
results[address] = ensName
return results
} catch (error) {
console.error(`Error lookupAddress ens for "${address}"`, error.message)
return results
}
}
export const lookupAddressesRequest = async (addressList, web3, registryContract) => {
const fetchNodeAddressesBatch = new web3.BatchRequest()
const fetchNodeAddresses = createFetchNodeAddresses(registryContract, fetchNodeAddressesBatch)
const fetchNodeAddressesPromises = uniq(addressList).map(fetchNodeAddresses)
fetchNodeAddressesBatch.execute()
const nodeAddresses = await Promise.all(fetchNodeAddressesPromises)
const results = {}
const fetchEnsNamesBatch = new web3.BatchRequest()
const fetchEnsNames = createFetchEnsNames(web3, fetchEnsNamesBatch, results)
const fetchEnsNamesPromises = nodeAddresses.map(fetchEnsNames)
fetchEnsNamesBatch.execute()
await Promise.all(fetchEnsNamesPromises)
return results
}
export const lookupAddresses = async (addressList, web3) => {
const registryContract = await web3.eth.ens.registry.contract
// web3.eth.ens._lastSyncCheck = Date.now() // - need for test in fork
const addressListChunks = chunk(addressList, CHUNK_COUNT_PER_BATCH_REQUEST)
let results = {}
for await (const list of addressListChunks) {
const result = await lookupAddressesRequest(list, web3, registryContract)
results = { ...results, ...result }
}
return results
}

View File

@ -1,5 +1,6 @@
/* eslint-disable no-console */ /* eslint-disable no-console */
/* eslint-disable import/order */ /* eslint-disable import/order */
import Web3 from 'web3' import Web3 from 'web3'
import { utils } from 'ethers' import { utils } from 'ethers'
import { ToastProgrammatic as Toast } from 'buefy' import { ToastProgrammatic as Toast } from 'buefy'
@ -16,19 +17,17 @@ const state = () => {
approvalAmount: 'unlimited', approvalAmount: 'unlimited',
lockedBalance: '0', lockedBalance: '0',
isFetchingLockedBalance: false, isFetchingLockedBalance: false,
isFetchingProposalComments: false,
currentDelegate: '0x0000000000000000000000000000000000000000', currentDelegate: '0x0000000000000000000000000000000000000000',
timestamp: 0, timestamp: 0,
delegatedBalance: '0', delegatedBalance: '0',
isFetchingDelegatedBalance: false, isFetchingDelegatedBalance: false,
delegators: [], delegators: [],
proposalComments: [],
latestProposalId: { latestProposalId: {
value: null, value: null,
status: null status: null
}, },
isFetchingProposals: true, isFetchingProposals: true,
isSaveProposal: false, isCastingVote: false,
proposals: [], proposals: [],
voterReceipts: [], voterReceipts: [],
hasActiveProposals: false, hasActiveProposals: false,
@ -74,9 +73,6 @@ const getters = {
return isFetchingProposals return isFetchingProposals
}, },
isFetchingProposalComments: (state) => {
return state.isFetchingProposalComments
},
votingPower: (state) => { votingPower: (state) => {
return toBN(state.lockedBalance) return toBN(state.lockedBalance)
.add(toBN(state.delegatedBalance)) .add(toBN(state.delegatedBalance))
@ -108,11 +104,8 @@ const mutations = {
SAVE_FETCHING_PROPOSALS(state, status) { SAVE_FETCHING_PROPOSALS(state, status) {
this._vm.$set(state, 'isFetchingProposals', status) this._vm.$set(state, 'isFetchingProposals', status)
}, },
SAVE_SAVE_PROPOSAL(state, status) { SAVE_CASTING_VOTE(state, status) {
this._vm.$set(state, 'isSaveProposal', status) this._vm.$set(state, 'isCastingVote', status)
},
SAVE_FETCHING_PROPOSAL_COMMENTS(state, status) {
this._vm.$set(state, 'isFetchingProposalComments', status)
}, },
SAVE_LOCKED_BALANCE(state, { balance }) { SAVE_LOCKED_BALANCE(state, { balance }) {
this._vm.$set(state, 'lockedBalance', balance) this._vm.$set(state, 'lockedBalance', balance)
@ -129,9 +122,6 @@ const mutations = {
SAVE_DELEGATEE(state, { currentDelegate }) { SAVE_DELEGATEE(state, { currentDelegate }) {
this._vm.$set(state, 'currentDelegate', currentDelegate) this._vm.$set(state, 'currentDelegate', currentDelegate)
}, },
SAVE_PROPOSAL_COMMENTS(state, proposalComments) {
state.proposalComments = proposalComments
},
SAVE_PROPOSALS(state, proposals) { SAVE_PROPOSALS(state, proposals) {
this._vm.$set(state, 'proposals', proposals) this._vm.$set(state, 'proposals', proposals)
}, },
@ -359,7 +349,7 @@ const actions = {
const { getters, rootGetters, commit, rootState, dispatch, state } = context const { getters, rootGetters, commit, rootState, dispatch, state } = context
const { id, support, contact = '', message = '' } = payload const { id, support, contact = '', message = '' } = payload
commit('SAVE_SAVE_PROPOSAL', true) commit('SAVE_CASTING_VOTE', true)
try { try {
const { ethAccount } = rootState.metamask const { ethAccount } = rootState.metamask
@ -433,7 +423,7 @@ const actions = {
) )
} finally { } finally {
dispatch('loading/disable', {}, { root: true }) dispatch('loading/disable', {}, { root: true })
commit('SAVE_SAVE_PROPOSAL', false) commit('SAVE_CASTING_VOTE', false)
} }
}, },
async executeProposal({ getters, rootGetters, commit, rootState, dispatch }, { id }) { async executeProposal({ getters, rootGetters, commit, rootState, dispatch }, { id }) {
@ -669,7 +659,7 @@ const actions = {
const [events, statuses] = await Promise.all([ const [events, statuses] = await Promise.all([
govInstance.getPastEvents('ProposalCreated', { govInstance.getPastEvents('ProposalCreated', {
fromBlock: config.constants.GOVERNANCE_TORNADOCASH_BLOCK, fromBlock: config.constants.GOVERNANCE_BLOCK,
toBlock: 'latest' toBlock: 'latest'
}), }),
aggregatorContract.methods.getAllProposals(govInstance._address).call() aggregatorContract.methods.getAllProposals(govInstance._address).call()
@ -819,14 +809,14 @@ const actions = {
filter: { filter: {
to: ethAccount to: ethAccount
}, },
fromBlock: config.constants.GOVERNANCE_TORNADOCASH_BLOCK, fromBlock: config.constants.GOVERNANCE_BLOCK,
toBlock: 'latest' toBlock: 'latest'
}) })
let undelegatedAccs = await govInstance.getPastEvents('Undelegated', { let undelegatedAccs = await govInstance.getPastEvents('Undelegated', {
filter: { filter: {
from: ethAccount from: ethAccount
}, },
fromBlock: config.constants.GOVERNANCE_TORNADOCASH_BLOCK, fromBlock: config.constants.GOVERNANCE_BLOCK,
toBlock: 'latest' toBlock: 'latest'
}) })
delegatedAccs = delegatedAccs.map((acc) => acc.returnValues.account) delegatedAccs = delegatedAccs.map((acc) => acc.returnValues.account)
@ -883,109 +873,6 @@ const actions = {
console.error('fetchReceipt', e.message) console.error('fetchReceipt', e.message)
} }
}, },
async fetchProposalComments(context, payload) {
const { getters, rootGetters, commit, state } = context
const { id: proposalId } = payload
let { blockNumber: fromBlock } = payload
commit('SAVE_FETCHING_PROPOSAL_COMMENTS', true)
let { proposalComments } = state
if (proposalComments[0]?.id === proposalId) {
fromBlock = proposalComments[0].blockNumber + 1
} else {
commit('SAVE_PROPOSAL_COMMENTS', [])
proposalComments = []
}
try {
const netId = rootGetters['metamask/netId']
console.log('fetchProposalComments', proposalId)
const govInstance = getters.govContract({ netId })
const web3 = getters.getWeb3({ netId })
const CACHE_TX = {}
const CACHE_BLOCK = {}
const getComment = (calldata) => {
const empty = { contact: '', message: '' }
if (!calldata) return empty
const methodLength = 4 // length of castDelegatedVote method
const result = utils.defaultAbiCoder.decode(
['address[]', 'uint256', 'bool'],
utils.hexDataSlice(calldata, methodLength)
)
const data = govInstance.methods.castDelegatedVote(...result).encodeABI()
const dataLength = utils.hexDataLength(data)
try {
const str = utils.defaultAbiCoder.decode(['string'], utils.hexDataSlice(calldata, dataLength))
const [contact, message] = JSON.parse(str)
return { contact, message }
} catch {
return empty
}
}
let votedEvents = await govInstance.getPastEvents('Voted', {
filter: {
// support: [false],
proposalId
},
fromBlock,
toBlock: 'latest'
})
votedEvents = votedEvents.filter((event) => event.blockNumber >= fromBlock)
const promises = votedEvents.map(async (votedEvent) => {
const { transactionHash, returnValues, blockNumber } = votedEvent
const { voter, support } = returnValues
CACHE_TX[transactionHash] = CACHE_TX[transactionHash] || web3.eth.getTransaction(transactionHash)
CACHE_BLOCK[blockNumber] = CACHE_BLOCK[blockNumber] || web3.eth.getBlock(blockNumber)
const [tx, blockInfo] = await Promise.all([CACHE_TX[transactionHash], CACHE_BLOCK[blockNumber]])
const isMaybeHasComment = support === false && voter === tx.from
const comment = isMaybeHasComment ? getComment(tx.input) : getComment()
return {
id: `${transactionHash}-${voter}`,
proposalId,
...returnValues,
...comment,
revote: false,
votes: fromWei(returnValues.votes),
transactionHash,
from: tx.from,
delegator: voter === tx.from ? null : tx.from,
timestamp: blockInfo.timestamp,
blockNumber
}
})
let newProposalComments = await Promise.all(promises)
newProposalComments = newProposalComments
.filter(Boolean)
.concat(proposalComments)
.sort((a, b) => (b.timestamp - a.timestamp || b.delegator ? -1 : 0))
const voters = {}
newProposalComments = newProposalComments.map((comment) => {
const revote = voters[comment.voter] ?? false
voters[comment.voter] = true
return { ...comment, revote }
})
commit('SAVE_PROPOSAL_COMMENTS', newProposalComments)
} catch (e) {
console.error('fetchProposalComments', e.message)
}
commit('SAVE_FETCHING_PROPOSAL_COMMENTS', false)
},
async fetchUserData({ getters, rootGetters, commit, rootState, dispatch }) { async fetchUserData({ getters, rootGetters, commit, rootState, dispatch }) {
try { try {
commit('SAVE_FETCHING_LOCKED_BALANCE', true) commit('SAVE_FETCHING_LOCKED_BALANCE', true)

View File

@ -0,0 +1,269 @@
/* eslint-disable no-console */
/* eslint-disable import/order */
import { utils } from 'ethers'
import uniqBy from 'lodash/uniqBy'
import chunk from 'lodash/chunk'
import { lookupAddresses, createBatchRequestCallback } from '@/services'
import { CHUNK_COUNT_PER_BATCH_REQUEST } from '@/constants'
const { toWei, fromWei, toBN } = require('web3-utils')
const CACHE_TX = {}
const CACHE_BLOCK = {}
const parseComment = (calldata, govInstance) => {
const empty = { contact: '', message: '' }
if (!calldata || !govInstance) return empty
const methodLength = 4 // length of castDelegatedVote method
const result = utils.defaultAbiCoder.decode(
['address[]', 'uint256', 'bool'],
utils.hexDataSlice(calldata, methodLength)
)
const data = govInstance.methods.castDelegatedVote(...result).encodeABI()
const dataLength = utils.hexDataLength(data)
try {
const str = utils.defaultAbiCoder.decode(['string'], utils.hexDataSlice(calldata, dataLength))
const [contact, message] = JSON.parse(str)
return { contact, message }
} catch {
return empty
}
}
const createProposalComment = (resultAll, votedEvent) => {
const { transactionHash, returnValues, blockNumber } = votedEvent
const { voter } = returnValues
const comment = parseComment()
const percentage =
toBN(votedEvent.returnValues.votes)
.mul(toBN(10000))
.divRound(resultAll)
.toNumber() / 100
return {
id: `${transactionHash}-${voter}`,
percentage,
...returnValues,
votes: fromWei(returnValues.votes),
transactionHash,
blockNumber,
...comment,
ens: {
delegator: null,
voter: null
},
delegator: null,
timestamp: null
}
}
const createFetchCommentWithMessage = (web3, batch, govInstance) => async (proposalComment) => {
const { transactionHash, voter, blockNumber } = proposalComment
if (!CACHE_TX[transactionHash]) {
CACHE_TX[transactionHash] = new Promise((resolve, reject) => {
const callback = createBatchRequestCallback(resolve, reject)
batch.add(web3.eth.getTransaction.request(transactionHash, callback))
})
}
if (!CACHE_BLOCK[blockNumber]) {
CACHE_BLOCK[blockNumber] = new Promise((resolve, reject) => {
const callback = createBatchRequestCallback(resolve, reject)
batch.add(web3.eth.getBlock.request(blockNumber, callback))
})
}
try {
const [tx, blockInfo] = await Promise.all([CACHE_TX[transactionHash], CACHE_BLOCK[blockNumber]])
const isMaybeHasComment = voter === tx.from
const comment = parseComment(isMaybeHasComment ? tx.input : null, govInstance)
return {
...proposalComment,
...comment,
delegator: voter === tx.from ? null : tx.from,
timestamp: blockInfo.timestamp
}
} catch (error) {
CACHE_TX[transactionHash] = null
CACHE_BLOCK[blockNumber] = null
return proposalComment
}
}
const state = () => {
return {
isFetchingComments: false,
isFetchingMessages: false,
ensNames: {},
comments: []
}
}
const getters = {
comments: (state) => {
const { ensNames } = state
let comments = state.comments.slice()
comments.sort((a, b) => b.blockNumber - a.blockNumber)
comments = uniqBy(comments, 'voter')
comments.sort((a, b) => b.percentage - a.percentage)
comments = comments.map((data) => ({
...data,
ens: {
delegator: ensNames[data.delegator],
voter: ensNames[data.voter]
}
}))
return comments
}
}
const mutations = {
SAVE_FETCHING_COMMENTS(state, status) {
state.isFetchingComments = status
},
SAVE_FETCHING_MESSAGES(state, status) {
state.isFetchingMessages = status
},
SAVE_ENS_NAMES(state, ensNames) {
state.ensNames = { ...state.ensNames, ...ensNames }
},
SAVE_COMMENTS(state, comments) {
state.comments = comments
}
}
const actions = {
async fetchComments(context, proposal) {
const { commit, dispatch, state } = context
let { comments } = state
let newComments = []
if (comments[0]?.id !== proposal.id) {
commit('SAVE_COMMENTS', [])
comments = []
}
commit('SAVE_FETCHING_COMMENTS', true)
newComments = await dispatch('fetchVotedEvents', { proposal, comments })
commit('SAVE_FETCHING_COMMENTS', false)
if (!newComments) return
commit('SAVE_COMMENTS', newComments.concat(comments))
dispatch('fetchEnsNames', { comments: newComments })
commit('SAVE_FETCHING_MESSAGES', true)
// TODO: TC-163 - add pagination
newComments = await dispatch('fetchCommentsMessages', { comments: newComments })
commit('SAVE_FETCHING_MESSAGES', false)
if (!newComments) return
commit('SAVE_COMMENTS', newComments.concat(comments))
},
async fetchVotedEvents(context, { proposal, comments }) {
const { rootGetters } = context
let { blockNumber: fromBlock } = proposal
const netId = rootGetters['metamask/netId']
const govInstance = rootGetters['governance/gov/govContract']({ netId })
if (comments[0]?.id === proposal.id) {
fromBlock = comments[0].blockNumber + 1
}
try {
let votedEvents = await govInstance.getPastEvents('Voted', {
filter: {
// support: [false],
proposalId: proposal.id
},
fromBlock,
toBlock: 'latest'
})
console.log('fetchVotedEvents', votedEvents.length)
votedEvents = votedEvents.sort((a, b) => b.blockNumber - a.blockNumber)
votedEvents = uniqBy(votedEvents, 'returnValues.voter')
console.log('fetchVotedEvents uniq', votedEvents.length)
const resultAll = toBN(toWei(proposal.results.for)).add(toBN(toWei(proposal.results.against)))
let newComments = votedEvents.map((votedEvent) => createProposalComment(resultAll, votedEvent))
newComments = newComments.concat(comments)
return newComments
} catch (e) {
console.error('fetchVotedEvents', e.message)
return null
}
},
async fetchCommentsMessages(context, { comments }) {
const { rootGetters } = context
const netId = rootGetters['metamask/netId']
const govInstance = rootGetters['governance/gov/govContract']({ netId })
const web3 = rootGetters['governance/gov/getWeb3']({ netId })
const commentListChunks = chunk(comments, CHUNK_COUNT_PER_BATCH_REQUEST)
let results = []
try {
for await (const list of commentListChunks) {
const batch = new web3.BatchRequest()
const fetchCommentsWithMessages = createFetchCommentWithMessage(web3, batch, govInstance)
const promises = list.map(fetchCommentsWithMessages)
batch.execute()
const result = await Promise.all(promises)
results = results.concat(result)
}
return results
} catch (e) {
console.error('fetchCommentsMessages', e.message)
}
},
async fetchEnsNames(context, { comments }) {
const { rootGetters, commit } = context
const netId = rootGetters['metamask/netId']
const web3 = rootGetters['governance/gov/getWeb3']({ netId })
try {
const addresses = comments
.map((_) => _.voter)
.flat()
.filter(Boolean)
console.log('fetchEnsNames', addresses.length)
const ensNames = await lookupAddresses(addresses, web3)
commit('SAVE_ENS_NAMES', ensNames)
} catch (e) {
console.error('fetchEnsNames', e.message)
}
}
}
export default {
namespaced: true,
state,
getters,
mutations,
actions
}

View File

@ -10,6 +10,30 @@ export const sliceAddress = (address) => {
return '0x' + hashRender(address.slice(2)) return '0x' + hashRender(address.slice(2))
} }
export const sliceEnsName = (name, size = 4, separator = '...') => {
const chars = [...name]
const last = name
.split('.')
.pop()
.slice(-size)
if (chars[0]?.length === 2 && last) {
// 🐵🍆💦.eth -> 🐵🍆💦.eth
if (chars.length - 4 <= 4) return name
// 🦍🦍🦍🦍🦍🦍🦍.eth -> 🦍🦍🦍...eth
return [].concat(chars.slice(0, 3), separator, last).join('')
}
if (chars.length <= 2 * size + 2 + separator.length) return name
if (!name.includes('.')) return sliceAddress(name, size, separator)
return last.length
? [].concat(chars.slice(0, 2 * size - last.length), separator, last).join('')
: [].concat(chars.slice(0, size), separator, chars.slice(-size)).join('')
}
const semVerRegex = /^(?<major>0|[1-9]\d*)\.(?<minor>0|[1-9]\d*)\.(?<patch>0|[1-9]\d*)(?:-(?<prerelease>(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+(?<buildmetadata>[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/ const semVerRegex = /^(?<major>0|[1-9]\d*)\.(?<minor>0|[1-9]\d*)\.(?<patch>0|[1-9]\d*)(?:-(?<prerelease>(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+(?<buildmetadata>[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/
export const parseSemanticVersion = (version) => { export const parseSemanticVersion = (version) => {