Merge pull request #50 from dternyak/contribution-validation

Blockchain validation into develop
This commit is contained in:
Daniel Ternyak 2019-01-10 13:51:19 -06:00 committed by GitHub
commit f6ba6e3dcb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
46 changed files with 820 additions and 722 deletions

View File

@ -206,7 +206,7 @@ const app = store({
}
app.proposalDetailApproving = false;
},
async getEmailExample(type: string) {
try {
const example = await getEmailExample(type);

View File

@ -22,6 +22,5 @@ TWITTER_CLIENT_SECRET=twitter-client-secret
LINKEDIN_CLIENT_ID=linkedin-client-id
LINKEDIN_CLIENT_SECRET=linkedin-client-secret
BLOCKCHAIN_WS_API_URL="http://localhost:5050"
BLOCKCHAIN_REST_API_URL="http://localhost:5051"
BLOCKCHAIN_API_SECRET="ef0b48e41f78d3ae85b1379b386f1bca"
BLOCKCHAIN_API_SECRET="ef0b48e41f78d3ae85b1379b386f1bca"

View File

@ -2,24 +2,26 @@ import requests
import json
from grant.settings import BLOCKCHAIN_REST_API_URL, BLOCKCHAIN_API_SECRET
### REST API ###
def handle_res(res):
j = res.json()
if j.get('error'):
raise Exception('Blockchain API Error: {}'.format(j['error']))
return j['data']
j = res.json()
if j.get('error'):
raise Exception('Blockchain API Error: {}'.format(j['error']))
return j['data']
def blockchain_get(path, params = None):
res = requests.get(
f'{BLOCKCHAIN_REST_API_URL}{path}',
headers={ 'authorization': BLOCKCHAIN_API_SECRET },
params=params,
)
return handle_res(res)
res = requests.get(
f'{BLOCKCHAIN_REST_API_URL}{path}',
headers={ 'authorization': BLOCKCHAIN_API_SECRET },
params=params,
)
return handle_res(res)
def blockchain_post(path, data = None):
res = requests.post(
f'{BLOCKCHAIN_REST_API_URL}{path}',
headers={ 'authorization': BLOCKCHAIN_API_SECRET },
data=json.dumps(data),
)
return handle_res(res)
res = requests.post(
f'{BLOCKCHAIN_REST_API_URL}{path}',
headers={ 'authorization': BLOCKCHAIN_API_SECRET },
data=json.dumps(data),
)
return handle_res(res)

View File

@ -1,6 +1,7 @@
import datetime
from typing import List
from sqlalchemy import func, or_
from functools import reduce
from grant.comment.models import Comment
from grant.extensions import ma, db
@ -95,6 +96,8 @@ class ProposalContribution(db.Model):
amount = db.Column(db.String(255), nullable=False)
tx_id = db.Column(db.String(255))
user = db.relationship("User")
def __init__(
self,
proposal_id: int,
@ -108,10 +111,21 @@ class ProposalContribution(db.Model):
self.status = PENDING
@staticmethod
def getExistingContribution(user_id: int, proposal_id: int, amount: str):
def get_existing_contribution(user_id: int, proposal_id: int, amount: str):
return ProposalContribution.query.filter_by(
user_id=user_id,
proposal_id=proposal_id,
amount=amount,
status=PENDING,
).first()
@staticmethod
def get_by_userid(user_id):
return ProposalContribution.query \
.filter_by(user_id=user_id, proposal_id=proposal_id, amount=amount) \
.first()
.filter(ProposalContribution.user_id == user_id) \
.filter(ProposalContribution.status != DELETED) \
.order_by(ProposalContribution.date_created.desc()) \
.all()
def confirm(self, tx_id: str, amount: str):
self.status = CONFIRMED
@ -300,7 +314,6 @@ class ProposalSchema(ma.Schema):
"content",
"comments",
"updates",
"contributions",
"milestones",
"category",
"team",
@ -317,7 +330,6 @@ class ProposalSchema(ma.Schema):
comments = ma.Nested("CommentSchema", many=True)
updates = ma.Nested("ProposalUpdateSchema", many=True)
contributions = ma.Nested("ProposalContributionSchema", many=True, exclude=['proposal'])
team = ma.Nested("UserSchema", many=True)
milestones = ma.Nested("MilestoneSchema", many=True)
invites = ma.Nested("ProposalTeamInviteSchema", many=True)
@ -335,13 +347,30 @@ class ProposalSchema(ma.Schema):
return dt_to_unix(obj.date_published) if obj.date_published else None
def get_funded(self, obj):
# TODO: Add up all contributions and return that
return "0"
contributions = ProposalContribution.query \
.filter_by(proposal_id=obj.id, status=CONFIRMED) \
.all()
funded = reduce(lambda prev, c: prev + float(c.amount), contributions, 0)
return str(funded)
proposal_schema = ProposalSchema()
proposals_schema = ProposalSchema(many=True)
user_fields = [
"proposal_id",
"status",
"title",
"brief",
"target",
"funded",
"date_created",
"date_approved",
"date_published",
"reject_reason",
"team",
]
user_proposal_schema = ProposalSchema(only=user_fields)
user_proposals_schema = ProposalSchema(many=True, only=user_fields)
class ProposalUpdateSchema(ma.Schema):
class Meta:
@ -433,7 +462,7 @@ class ProposalContributionSchema(ma.Schema):
)
proposal = ma.Nested("ProposalSchema")
user = ma.Nested("UserSchema")
user = ma.Nested("UserSchema", exclude=["email_address"])
date_created = ma.Method("get_date_created")
addresses = ma.Method("get_addresses")
@ -445,41 +474,9 @@ class ProposalContributionSchema(ma.Schema):
proposal_contribution_schema = ProposalContributionSchema()
proposals_contribution_schema = ProposalContributionSchema(many=True)
proposal_contributions_schema = ProposalContributionSchema(many=True)
user_proposal_contribution_schema = ProposalContributionSchema(exclude=['user', 'addresses'])
user_proposal_contributions_schema = ProposalContributionSchema(many=True, exclude=['user', 'addresses'])
proposal_proposal_contribution_schema = ProposalContributionSchema(exclude=['proposal', 'addresses'])
proposal_proposal_contributions_schema = ProposalContributionSchema(many=True, exclude=['proposal', 'addresses'])
class UserProposalSchema(ma.Schema):
class Meta:
model = Proposal
# Fields to expose
fields = (
"proposal_id",
"status",
"title",
"brief",
"target",
"funded",
"date_created",
"date_approved",
"date_published",
"reject_reason",
"team",
)
date_created = ma.Method("get_date_created")
proposal_id = ma.Method("get_proposal_id")
funded = ma.Method("get_funded")
team = ma.Nested("UserSchema", many=True)
def get_proposal_id(self, obj):
return obj.id
def get_date_created(self, obj):
return dt_to_unix(obj.date_created) * 1000
def get_funded(self, obj):
# TODO: Add up all contributions and return that
return "0"
user_proposal_schema = UserProposalSchema()
user_proposals_schema = UserProposalSchema(many=True)

View File

@ -11,10 +11,10 @@ from grant.comment.models import Comment, comment_schema, comments_schema
from grant.milestone.models import Milestone
from grant.user.models import User, SocialMedia, Avatar
from grant.email.send import send_email
from grant.utils.auth import requires_auth, requires_team_member_auth, get_authed_user
from grant.utils.auth import requires_auth, requires_team_member_auth, get_authed_user, internal_webhook
from grant.utils.exceptions import ValidationException
from grant.utils.misc import is_email, make_url
from .models import(
from grant.utils.misc import is_email, make_url, from_zat
from .models import (
Proposal,
proposals_schema,
proposal_schema,
@ -25,13 +25,15 @@ from .models import(
proposal_team,
ProposalTeamInvite,
proposal_team_invite_schema,
proposal_proposal_contributions_schema,
db,
DRAFT,
PENDING,
APPROVED,
REJECTED,
LIVE,
DELETED
DELETED,
CONFIRMED,
)
import traceback
@ -328,11 +330,25 @@ def delete_proposal_team_invite(proposal_id, id_or_address):
@endpoint.api()
def get_proposal_contributions(proposal_id):
proposal = Proposal.query.filter_by(id=proposal_id).first()
if proposal:
dumped_proposal = proposal_schema.dump(proposal)
return dumped_proposal["contributions"]
else:
if not proposal:
return {"message": "No proposal matching id"}, 404
top_contributions = ProposalContribution.query \
.filter_by(proposal_id=proposal_id, status=CONFIRMED) \
.order_by(ProposalContribution.amount.desc()) \
.limit(5) \
.all()
latest_contributions = ProposalContribution.query \
.filter_by(proposal_id=proposal_id, status=CONFIRMED) \
.order_by(ProposalContribution.date_created.desc()) \
.limit(5) \
.all()
return {
'top': proposal_proposal_contributions_schema.dump(top_contributions),
'latest': proposal_proposal_contributions_schema.dump(latest_contributions),
}
@blueprint.route("/<proposal_id>/contributions/<contribution_id>", methods=["GET"])
@ -361,7 +377,7 @@ def post_proposal_contribution(proposal_id, amount):
code = 200
contribution = ProposalContribution \
.getExistingContribution(g.current_user.id, proposal_id, amount)
.get_existing_contribution(g.current_user.id, proposal_id, amount)
if not contribution:
code = 201
@ -375,3 +391,49 @@ def post_proposal_contribution(proposal_id, amount):
dumped_contribution = proposal_contribution_schema.dump(contribution)
return dumped_contribution, code
# Can't use <proposal_id> since webhook doesn't know proposal id
@blueprint.route("/contribution/<contribution_id>/confirm", methods=["POST"])
@internal_webhook
@endpoint.api(
parameter('to', type=str, required=True),
parameter('amount', type=str, required=True),
parameter('txid', type=str, required=True),
)
def post_contribution_confirmation(contribution_id, to, amount, txid):
contribution = contribution = ProposalContribution.query.filter_by(
id=contribution_id).first()
if not contribution:
# TODO: Log in sentry
print(f'Unknown contribution {contribution_id} confirmed with txid {txid}')
return {"message": "No contribution matching id"}, 404
# Convert to whole zcash coins from zats
zec_amount = str(from_zat(int(amount)))
contribution.confirm(tx_id=txid, amount=zec_amount)
db.session.add(contribution)
db.session.commit()
return None, 200
@blueprint.route("/contribution/<contribution_id>", methods=["DELETE"])
@requires_auth
@endpoint.api()
def delete_proposal_contribution(contribution_id):
contribution = contribution = ProposalContribution.query.filter_by(
id=contribution_id).first()
if not contribution:
return {"message": "No contribution matching id"}, 404
if contribution.status == CONFIRMED:
return {"message": "Cannot delete confirmed contributions"}, 400
if contribution.user_id != g.current_user.id:
return {"message": "Must be the user of the contribution to delete it"}, 403
contribution.status = DELETED
db.session.add(contribution)
db.session.commit()
return None, 202

View File

@ -56,6 +56,5 @@ TWITTER_CLIENT_SECRET = env.str("TWITTER_CLIENT_SECRET")
LINKEDIN_CLIENT_ID = env.str("LINKEDIN_CLIENT_ID")
LINKEDIN_CLIENT_SECRET = env.str("LINKEDIN_CLIENT_SECRET")
BLOCKCHAIN_WS_API_URL = env.str("BLOCKCHAIN_WS_API_URL")
BLOCKCHAIN_REST_API_URL = env.str("BLOCKCHAIN_REST_API_URL")
BLOCKCHAIN_API_SECRET = env.str("BLOCKCHAIN_API_SECRET")

View File

@ -8,10 +8,13 @@ from grant.proposal.models import (
proposal_team,
ProposalTeamInvite,
invites_with_proposal_schema,
ProposalContribution,
user_proposal_contributions_schema,
user_proposals_schema,
PENDING,
APPROVED,
REJECTED
REJECTED,
CONFIRMED
)
from grant.utils.auth import requires_auth, requires_same_user_auth, get_authed_user
from grant.utils.upload import remove_avatar, sign_avatar_upload, AvatarException
@ -62,19 +65,21 @@ def get_user(user_id, with_proposals, with_comments, with_funded, with_pending):
user = User.get_by_id(user_id)
if user:
result = user_schema.dump(user)
authed_user = get_authed_user()
if with_proposals:
proposals = Proposal.get_by_user(user)
proposals_dump = user_proposals_schema.dump(proposals)
result["createdProposals"] = proposals_dump
result["proposals"] = proposals_dump
if with_funded:
contributions = Proposal.get_by_user_contribution(user)
contributions_dump = user_proposals_schema.dump(contributions)
result["fundedProposals"] = contributions_dump
contributions = ProposalContribution.get_by_userid(user_id)
if not authed_user or user.id != authed_user.id:
contributions = [c for c in contributions if c.status == CONFIRMED]
contributions_dump = user_proposal_contributions_schema.dump(contributions)
result["contributions"] = contributions_dump
if with_comments:
comments = Comment.get_by_user(user)
comments_dump = user_comments_schema.dump(comments)
result["comments"] = comments_dump
authed_user = get_authed_user()
if with_pending and authed_user and authed_user.id == user.id:
pending = Proposal.get_by_user(user, [PENDING, APPROVED, REJECTED])
pending_dump = user_proposals_schema.dump(pending)

View File

@ -7,7 +7,7 @@ from flask_security.core import current_user
from flask import request, g, jsonify
import sentry_sdk
from grant.settings import SECRET_KEY
from grant.settings import SECRET_KEY, BLOCKCHAIN_API_SECRET
from ..proposal.models import Proposal
from ..user.models import User
@ -67,3 +67,16 @@ def requires_team_member_auth(f):
return f(*args, **kwargs)
return requires_auth(decorated)
def internal_webhook(f):
@wraps(f)
def decorated(*args, **kwargs):
secret = request.headers.get('authorization')
if not secret:
print('Internal webhook missing "Authorization" header')
return jsonify(message="Invalid 'Authorization' header"), 403
if BLOCKCHAIN_API_SECRET not in secret:
print(f'Internal webhook provided invalid "Authorization" header: {secret}')
return jsonify(message="Invalid 'Authorization' header"), 403
return f(*args, **kwargs)
return decorated

View File

@ -30,3 +30,9 @@ def make_url(path: str):
def is_email(email: str):
return bool(re.match(r"[^@]+@[^@]+\.[^@]+", email))
def from_zat(zat: int):
return zat / 100000000
def to_zat(zec: float):
return zec * 100000000

View File

@ -2,8 +2,8 @@
API_SECRET_HASH="4747c3a7d043640cd8ed4e6b72bb9562d2585cee65681eb9c21ffec03c0bf560"
API_SECRET_KEY="ef0b48e41f78d3ae85b1379b386f1bca"
# WebSocket Config
WS_PORT="5050"
# Webhooks Config
WEBHOOK_URL="http://localhost:5000/api/v1"
# REST Server Config
REST_SERVER_PORT="5051"

View File

@ -31,6 +31,7 @@
"@types/cors": "2.8.4",
"@types/dotenv": "^6.1.0",
"@types/ws": "^6.0.1",
"axios": "0.18.0",
"body-parser": "1.18.3",
"cors": "2.8.5",
"dotenv": "^6.1.0",

View File

@ -6,7 +6,7 @@ interface CustomEnvironment {
API_SECRET_HASH: string;
API_SECRET_KEY: string;
WS_PORT: string;
WEBHOOK_URL: string;
REST_SERVER_PORT: string;
ZCASH_NODE_URL: string;

View File

@ -1,18 +1,19 @@
import * as Websocket from "./websocket";
import * as Webhooks from "./webhooks";
import * as RestServer from "./server";
import { initNode } from './node';
async function start() {
console.log("============== Starting services ==============");
await initNode();
await Websocket.start();
await Webhooks.start();
await RestServer.start();
console.log("===============================================");
}
process.on("SIGINT", () => {
console.log('Shutting down services...');
Websocket.exit();
Webhooks.exit();
RestServer.exit();
console.log('Exiting!');
process.exit();
});

View File

@ -1,6 +1,7 @@
import { randomBytes, createHmac } from "crypto";
import { IncomingMessage } from "http";
import { HDPublicKey, Address } from "zcash-bitcore-lib";
import { parse } from 'url';
import env from "./env";
function sha256(input: string) {
@ -14,22 +15,6 @@ export function generateApiKey() {
return { key, hash };
}
export function getIpFromRequest(req: IncomingMessage) {
const xffHeader = req.headers["x-forwarded-for"];
if (xffHeader && typeof xffHeader === "string") {
return xffHeader.split(/\s*,\s*/)[0];
}
return req.connection.remoteAddress;
}
export function getAuthFromRequest(req: IncomingMessage) {
const swpHeader = req.headers["sec-websocket-protocol"];
if (swpHeader && typeof swpHeader === "string") {
return swpHeader;
}
return undefined;
}
export function authenticate(secret: string) {
const hash = env.API_SECRET_HASH;
if (!hash) {
@ -38,14 +23,6 @@ export function authenticate(secret: string) {
return hash === sha256(secret);
}
export function authenticateRequest(req: IncomingMessage) {
const secret = getAuthFromRequest(req);
if (!secret) {
console.log(`Client must set 'sec-websocket-protocal' header with secret.`);
}
return secret ? authenticate(secret) : false;
}
// TODO: Not fully confident in compatibility with most bip32 wallets,
// do more work to ensure this is reliable.
export function deriveTransparentAddress(index: number, network: any) {

View File

@ -1,47 +1,25 @@
import WebSocket from "ws";
import axios from 'axios';
import { initializeNotifiers } from "./notifiers";
import { Notifier } from "./notifiers/notifier";
import { getIpFromRequest, authenticateRequest } from "../util";
import node, { rpcOptions } from "../node";
import node from "../node";
import env from "../env";
const log = console.log;
export type Send = (message: Message) => void;
export type Receive = (message: Message) => void;
export type Send = (route: string, method: string, payload: object) => void;
export interface Message {
type: string;
payload: any;
}
const parse = (data: WebSocket.Data) => {
try {
return JSON.parse(data.toString());
} catch (e) {
log(
`unable to parse message, it was probably not JSON, data: ${data}`
);
return null;
}
};
let wss: null | WebSocket.Server = null;
let notifiers = [] as Notifier[];
let consecutiveBlockFailures = 0;
const MAXIMUM_BLOCK_FAILURES = 5;
export async function start() {
await initNode();
initWebsocketServer();
initNotifiers();
}
export function exit() {
notifiers.forEach(n => n.destroy && n.destroy());
wss && wss.close();
wss = null;
console.log('WebSocket server has been closed');
console.log('Webhook notifiers have exited');
}
@ -82,47 +60,30 @@ async function initNode() {
}, 3000);
}
function initWebsocketServer() {
if (wss) return;
wss = new WebSocket.Server({
port: parseInt(env.WS_PORT as string, 10)
});
log(`WebSocket Server started on port ${env.WS_PORT}`);
wss.on("connection", function connection(ws, req) {
log(`${getIpFromRequest(req)} connected`);
const isAuth = authenticateRequest(req);
if (!isAuth) {
log(`Connection ${getIpFromRequest(req)} rejected, unauthorized.`);
ws.send(JSON.stringify({ type: "auth", payload: "rejected" }));
ws.terminate();
return;
}
ws.on("message", message => {
const parsedMsg = parse(message);
if (parsedMsg) {
notifiers.forEach(n => n.receive && n.receive(parsedMsg));
}
});
ws.on("close", () => {
log(`${getIpFromRequest(req)} closed.`);
});
});
}
function initNotifiers() {
const send: Send = message =>
wss &&
wss.clients.forEach(ws => {
try {
ws.send(JSON.stringify(message));
} catch (e) {
log(`Send error: ${e}`);
const send: Send = (route, method, payload) => {
console.log('About to send to', route);
axios.request({
method,
url: `${env.WEBHOOK_URL}${route}`,
data: payload,
headers: {
'Authorization': `Bearer ${env.API_SECRET_KEY}`,
'Content-Type': 'application/json',
},
})
.then((res) => {
if (res.status >= 400) {
console.error(`Webhook server responded to ${method} ${route} with status code ${res.status}`);
console.error(res.data);
}
})
.catch((err) => {
console.error(err);
console.error('Webhook server request failed! See above for details.');
});
};
notifiers = initializeNotifiers();
notifiers.forEach(n => n.registerSend(send));

View File

@ -1,4 +1,4 @@
import { Send, Message } from "../../index";
import { Send } from "../../index";
import { Notifier } from "../notifier";
import node, { BlockWithTransactions } from "../../../node";
import {
@ -43,7 +43,6 @@ export default class ContributionNotifier implements Notifier {
// generate one, so all of our addresses will only have addresses[0]
const to = vout.scriptPubKey.addresses[0];
if (tAddressIdMap[to]) {
console.info(`Transaction found for contribution ${tAddressIdMap[to]}, +${vout.value} ZEC`);
this.sendContributionConfirmation({
to,
amount: vout.valueZat.toString(),
@ -67,10 +66,11 @@ export default class ContributionNotifier implements Notifier {
const newReceived = received.filter(r => !this.confirmedTxIds.includes(r.txid));
newReceived.forEach(receipt => {
console.info(`Received new tx ${receipt.txid}`);
this.confirmedTxIds.push(receipt.txid);
const contributionId = getContributionIdFromMemo(receipt.memo);
if (!contributionId) {
console.warn('Sprout address received transaction without memo:\n', {
console.warn(`Sprout address ${env.SPROUT_ADDRESS} received transaction with invalid memo:\n`, {
txid: receipt.txid,
decodedMemo: decodeHexMemo(receipt.memo)
});
@ -134,10 +134,8 @@ export default class ContributionNotifier implements Notifier {
}
};
private sendContributionConfirmation = (payload: ContributionConfirmationPayload) => {
this.send({
payload,
type: 'contribution:confirmation',
});
private sendContributionConfirmation = (p: ContributionConfirmationPayload) => {
console.info(`Contribution confirmed for contribution ${p.contributionId}, +${p.amount} ZEC`);
this.send(`/proposals/contribution/${p.contributionId}/confirm`, 'POST', p);
};
}

View File

@ -1,9 +1,8 @@
import { Send, Message } from "../index";
import { Send } from "../index";
import { Block } from "../../node";
export interface Notifier {
registerSend(send: Send): void;
receive?(message: Message): void;
onNewBlock?(block: Block): void;
destroy?(): void;
}
}

File diff suppressed because it is too large Load Diff

View File

@ -33,6 +33,10 @@ export function getProposalUpdates(proposalId: number | string) {
return axios.get(`/api/v1/proposals/${proposalId}/updates`);
}
export function getProposalContributions(proposalId: number | string) {
return axios.get(`/api/v1/proposals/${proposalId}/contributions`);
}
export function postProposal(payload: ProposalDraft) {
return axios.post(`/api/v1/proposals/`, {
...payload,
@ -216,6 +220,7 @@ export function postProposalContribution(
});
}
export function postProposalComment(payload: {
proposalId: number;
parentCommentId?: number;
@ -224,3 +229,14 @@ export function postProposalComment(payload: {
const { proposalId, ...args } = payload;
return axios.post(`/api/v1/proposals/${proposalId}/comments`, args);
}
export function deleteProposalContribution(contributionId: string | number) {
return axios.delete(`/api/v1/proposals/contribution/${contributionId}`);
}
export function getProposalContribution(
proposalId: number,
contributionId: number,
): Promise<{ data: ContributionWithAddresses }> {
return axios.get(`/api/v1/proposals/${proposalId}/contributions/${contributionId}`);
}

View File

@ -1,25 +0,0 @@
import React from 'react';
import ShortAddress from 'components/ShortAddress';
import Identicon from 'components/Identicon';
import './style.less';
interface Props {
address: string;
secondary?: React.ReactNode;
}
const AddressRow = ({ address, secondary }: Props) => (
<div className="AddressRow">
<div className="AddressRow-avatar">
<Identicon address={address} />
</div>
<div className="AddressRow-info">
<div className="AddressRow-info-main">
<ShortAddress address={address} />
</div>
{secondary && <p className="AddressRow-info-secondary">{secondary}</p>}
</div>
</div>
);
export default AddressRow;

View File

@ -1,47 +0,0 @@
@height: 3rem;
.AddressRow {
position: relative;
display: flex;
height: @height;
margin-bottom: 1rem;
&:last-child {
margin-bottom: 0;
}
&-avatar {
display: block;
height: @height;
width: @height;
margin-right: 0.75rem;
img {
width: 100%;
border-radius: 4px;
}
}
&-info {
flex: 1;
min-width: 0;
&-main {
font-size: 1.1rem;
margin-bottom: 0.1rem;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
&-secondary {
font-size: 0.9rem;
opacity: 0.7;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
}

View File

@ -19,6 +19,7 @@
border-bottom: 1px solid rgba(#000, 0.06);
&-qr {
position: relative;
padding: 0.5rem;
margin-right: 1rem;
border-radius: 4px;
@ -28,6 +29,16 @@
canvas {
display: block;
}
&.is-loading canvas {
opacity: 0;
}
.ant-spin {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
}
&-info {

View File

@ -67,8 +67,13 @@ export default class PaymentInfo extends React.Component<Props, State> {
</Radio.Group>
<div className="PaymentInfo-uri">
<div className="PaymentInfo-uri-qr">
{uri ? <QRCode value={uri} /> : <Spin />}
<div className={
classnames('PaymentInfo-uri-qr', !uri && 'is-loading')
}>
<span style={{ opacity: uri ? 1 : 0 }}>
<QRCode value={uri || ''} />
</span>
{!uri && <Spin size="large" />}
</div>
<div className="PaymentInfo-uri-info">
<CopyInput

View File

@ -1,14 +1,16 @@
import React from 'react';
import { Modal } from 'antd';
import Result from 'ant-design-pro/lib/Result';
import { postProposalContribution } from 'api/api';
import { postProposalContribution, getProposalContribution } from 'api/api';
import { ContributionWithAddresses } from 'types';
import PaymentInfo from './PaymentInfo';
interface OwnProps {
isVisible: boolean;
proposalId: number;
proposalId?: number;
contributionId?: number;
amount?: string;
hasNoButtons?: boolean;
handleClose(): void;
}
@ -28,17 +30,21 @@ export default class ContributionModal extends React.Component<Props, State> {
};
componentWillUpdate(nextProps: Props) {
const { isVisible, proposalId } = nextProps
if (isVisible && this.props.isVisible !== isVisible) {
this.fetchAddresses(proposalId);
}
else if (proposalId !== this.props.proposalId) {
this.fetchAddresses(proposalId);
const { isVisible, proposalId, contributionId } = nextProps;
// When modal is opened and proposalId is provided or changed
if (isVisible && proposalId) {
if (
this.props.isVisible !== isVisible ||
proposalId !== this.props.proposalId
) {
this.fetchAddresses(proposalId, contributionId);
}
}
}
render() {
const { isVisible, handleClose } = this.props;
const { isVisible, handleClose, hasNoButtons } = this.props;
const { hasSent, contribution, error } = this.state;
let content;
@ -68,11 +74,12 @@ export default class ContributionModal extends React.Component<Props, State> {
<Modal
title="Make your contribution"
visible={isVisible}
closable={hasSent}
maskClosable={hasSent}
closable={hasSent || hasNoButtons}
maskClosable={hasSent || hasNoButtons}
okText={hasSent ? 'Done' : 'Ive sent it'}
onOk={hasSent ? handleClose : this.confirmSend}
onCancel={handleClose}
footer={hasNoButtons ? '' : undefined}
centered
>
{content}
@ -80,12 +87,20 @@ export default class ContributionModal extends React.Component<Props, State> {
);
}
private async fetchAddresses(proposalId: number) {
private async fetchAddresses(
proposalId: number,
contributionId?: number,
) {
try {
const res = await postProposalContribution(
proposalId,
this.props.amount || '0',
);
let res;
if (contributionId) {
res = await getProposalContribution(proposalId, contributionId);
} else {
res = await postProposalContribution(
proposalId,
this.props.amount || '0',
);
}
this.setState({ contribution: res.data });
} catch(err) {
this.setState({ error: err.message });

View File

@ -0,0 +1,80 @@
@small-query: ~'(max-width: 640px)';
.ProfileContribution {
display: flex;
align-items: center;
padding-bottom: 1.2rem;
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
margin-bottom: 1rem;
&:last-child {
border-bottom: none;
padding-bottom: none;
}
@media @small-query {
flex-direction: column;
align-items: flex-start;
padding-bottom: 0.6rem;
}
&-info {
flex: 1;
&-title {
display: flex;
align-items: center;
font-size: 1.2rem;
font-weight: 600;
color: inherit;
margin-bottom: 0.5rem;
.ant-tag {
margin-left: 0.3rem;
}
}
}
&-state {
margin-left: 1.2rem;
min-width: 15rem;
text-align: right;
@media @small-query {
margin-left: 0;
margin-top: 0.6rem;
text-align: left;
}
&-amount {
margin-bottom: 0.25rem;
font-size: 1rem;
// UnitDisplay symbol
> span {
font-size: 1.1rem;
> span {
font-weight: normal;
font-size: 0.8rem;
}
}
}
&-actions {
font-size: 0.7rem;
> * {
&:after {
content: '·';
display: inline-block;
padding: 0 0.3rem;
}
&:last-child:after {
display: none;
}
}
}
}
}

View File

@ -0,0 +1,94 @@
import React from 'react';
import { connect } from 'react-redux';
import { Link } from 'react-router-dom';
import { Tag, Popconfirm } from 'antd';
import UnitDisplay from 'components/UnitDisplay';
import { ONE_DAY } from 'utils/time';
import { formatTxExplorerUrl } from 'utils/formatters';
import { deleteContribution } from 'modules/users/actions';
import { UserContribution } from 'types';
import './ProfileContribution.less';
interface OwnProps {
userId: number;
contribution: UserContribution;
showSendInstructions(contribution: UserContribution): void;
}
interface DispatchProps {
deleteContribution: typeof deleteContribution;
}
type Props = OwnProps & DispatchProps;
class ProfileContribution extends React.Component<Props> {
render() {
const { contribution } = this.props;
const { proposal } = contribution;
const isConfirmed = contribution.status === 'CONFIRMED';
const isExpired = !isConfirmed && contribution.dateCreated < Date.now() / 1000 - ONE_DAY;
let tag;
let actions: React.ReactNode;
if (isConfirmed) {
actions = (
<a
href={formatTxExplorerUrl(contribution.txId as string)}
target="_blank"
rel="noopener nofollow"
>
View transaction
</a>
);
} else if (isExpired) {
tag = <Tag color="red">Expired</Tag>;
// TODO: Link to support
actions = <>
<Popconfirm
title="Are you sure?"
onConfirm={this.deleteContribution}
>
<a>Delete</a>
</Popconfirm>
<Link to="/support">Contact support</Link>
</>;
} else {
tag = <Tag color="orange">Pending</Tag>;
actions = (
<a onClick={() => this.props.showSendInstructions(contribution)}>
View send instructions
</a>
);
}
return (
<div className="ProfileContribution">
<div className="ProfileContribution-info">
<Link
className="ProfileContribution-info-title"
to={`/proposals/${proposal.proposalId}`}
>
{proposal.title} {tag}
</Link>
<div className="ProfileContribution-info-brief">{proposal.brief}</div>
</div>
<div className="ProfileContribution-state">
<div className="ProfileContribution-state-amount">
+<UnitDisplay value={contribution.amount} symbol="ZEC" />
</div>
<div className="ProfileContribution-state-actions">
{actions}
</div>
</div>
</div>
);
}
private deleteContribution = () => {
this.props.deleteContribution(this.props.userId, this.props.contribution.id);
};
}
export default connect<{}, DispatchProps, OwnProps, {}>(undefined, {
deleteContribution,
})(ProfileContribution);

View File

@ -16,11 +16,14 @@ import ProfileUser from './ProfileUser';
import ProfileEdit from './ProfileEdit';
import ProfilePendingList from './ProfilePendingList';
import ProfileProposal from './ProfileProposal';
import ProfileContribution from './ProfileContribution';
import ProfileComment from './ProfileComment';
import ProfileInvite from './ProfileInvite';
import Placeholder from 'components/Placeholder';
import Exception from 'pages/exception';
import ContributionModal from 'components/ContributionModal';
import './style.less';
import { UserContribution } from 'types';
interface StateProps {
usersMap: AppState['users']['map'];
@ -34,7 +37,15 @@ interface DispatchProps {
type Props = RouteComponentProps<any> & StateProps & DispatchProps;
class Profile extends React.Component<Props> {
interface State {
activeContribution: UserContribution | null;
}
class Profile extends React.Component<Props, State> {
state: State = {
activeContribution: null,
};
componentDidMount() {
this.fetchData();
}
@ -49,6 +60,8 @@ class Profile extends React.Component<Props> {
render() {
const userLookupParam = this.props.match.params.id;
const { authUser, match } = this.props;
const { activeContribution } = this.state;
if (!userLookupParam) {
if (authUser && authUser.userid) {
return <Redirect to={`/profile/${authUser.userid}`} />;
@ -69,16 +82,10 @@ class Profile extends React.Component<Props> {
return <Exception code="404" />;
}
const {
pendingProposals,
createdProposals,
fundedProposals,
comments,
invites,
} = user;
const { proposals, pendingProposals, contributions, comments, invites } = user;
const nonePending = pendingProposals.length === 0;
const noneCreated = createdProposals.length === 0;
const noneFunded = fundedProposals.length === 0;
const noneCreated = proposals.length === 0;
const noneFunded = contributions.length === 0;
const noneCommented = comments.length === 0;
const noneInvites = user.hasFetchedInvites && invites.length === 0;
@ -119,21 +126,26 @@ class Profile extends React.Component<Props> {
</div>
</Tabs.TabPane>
)}
<Tabs.TabPane tab={TabTitle('Created', createdProposals.length)} key="created">
<Tabs.TabPane tab={TabTitle('Created', proposals.length)} key="created">
<div>
{noneCreated && (
<Placeholder subtitle="Has not created any proposals yet" />
)}
{createdProposals.map(p => (
{proposals.map(p => (
<ProfileProposal key={p.proposalId} proposal={p} />
))}
</div>
</Tabs.TabPane>
<Tabs.TabPane tab={TabTitle('Funded', fundedProposals.length)} key="funded">
<Tabs.TabPane tab={TabTitle('Funded', contributions.length)} key="funded">
<div>
{noneFunded && <Placeholder subtitle="Has not funded any proposals yet" />}
{fundedProposals.map(p => (
<ProfileProposal key={p.proposalId} proposal={p} />
{contributions.map(c => (
<ProfileContribution
key={c.id}
userId={user.userid}
contribution={c}
showSendInstructions={this.openContributionModal}
/>
))}
</div>
</Tabs.TabPane>
@ -165,9 +177,20 @@ class Profile extends React.Component<Props> {
</Tabs.TabPane>
)}
</Tabs>
<ContributionModal
isVisible={!!activeContribution}
proposalId={
activeContribution ? activeContribution.proposal.proposalId : undefined
}
contributionId={activeContribution ? activeContribution.id : undefined}
hasNoButtons
handleClose={this.closeContributionModal}
/>
</div>
);
}
private fetchData() {
const { match } = this.props;
const userLookupId = match.params.id;
@ -176,6 +199,9 @@ class Profile extends React.Component<Props> {
this.props.fetchUserInvites(userLookupId);
}
}
private openContributionModal = (c: UserContribution) => this.setState({ activeContribution: c });
private closeContributionModal = () => this.setState({ activeContribution: null });
}
const TabTitle = (disp: string, count: number) => (

View File

@ -0,0 +1,46 @@
@import '~styles/variables.less';
.ProposalContributors {
display: flex;
@media @mobile-query {
flex-direction: column;
}
&-block {
flex: 1;
padding: 1.5rem;
margin: 1rem;
background: #FFF;
border-radius: 4px;
border: 1px solid #ddd;
box-shadow: 0 2px 3px rgba(0, 0, 0, 0.1),
0 1px 1px rgba(0, 0, 0, 0.1);
&:first-child {
margin-left: 0;
}
&:last-child {
margin-right: 0;
}
@media @mobile-query {
margin-left: 0;
margin-right: 0;
}
&-title {
margin-top: -0.6rem;
margin-bottom: 1rem;
font-size: 1rem;
}
&-contributor {
margin-bottom: 1rem;
&:last-child {
margin-bottom: 0;
}
}
}
}

View File

@ -1,53 +1,106 @@
import React from 'react';
import { connect } from 'react-redux';
import { Spin } from 'antd';
import AddressRow from 'components/AddressRow';
import UserRow from 'components/UserRow';
import Placeholder from 'components/Placeholder';
import UnitDisplay from 'components/UnitDisplay';
import { toZat } from 'utils/units';
import { fetchProposalContributions } from 'modules/proposals/actions';
import {
getProposalContributions,
getIsFetchingContributions,
getFetchContributionsError,
} from 'modules/proposals/selectors';
import { ContributionWithUser } from 'types';
import { AppState } from 'store/reducers';
import './index.less';
const ContributorsBlock = () => {
// TODO: Get contributors from proposal
console.warn('TODO: Get contributors from proposal for Proposal/Contributors/index.tsx');
const proposal = { contributors: [] as any };
let content;
if (proposal) {
if (proposal.contributors.length) {
content = proposal.contributors.map((contributor: any) => (
<AddressRow
key={contributor.address}
address={contributor.address}
secondary={
<UnitDisplay value={contributor.contributionAmount} symbol="ZEC" />
}
/>
));
} else {
content = (
<Placeholder
style={{ minHeight: '220px' }}
title="No contributors found"
subtitle={`
No contributions have been made to this proposal.
Check back later once there's been at least one contribution.
`}
/>
);
interface OwnProps {
proposalId: number;
}
interface StateProps {
contributions: ReturnType<typeof getProposalContributions>;
isFetchingContributions: ReturnType<typeof getIsFetchingContributions>;
fetchContributionsError: ReturnType<typeof getFetchContributionsError>;
}
interface DispatchProps {
fetchProposalContributions: typeof fetchProposalContributions;
}
type Props = OwnProps & StateProps & DispatchProps;
class ProposalContributors extends React.Component<Props> {
componentDidMount() {
if (this.props.proposalId) {
this.props.fetchProposalContributions(this.props.proposalId);
}
} else {
content = <Spin />;
}
return (
<div className="Proposal-top-side-block">
{proposal.contributors.length ? (
<>
<h1 className="Proposal-top-main-block-title">Contributors</h1>
<div className="Proposal-top-main-block">{content}</div>
</>
) : (
content
)}
</div>
);
componentWillReceiveProps(nextProps: Props) {
if (nextProps.proposalId && nextProps.proposalId !== this.props.proposalId) {
this.props.fetchProposalContributions(nextProps.proposalId);
}
}
render() {
const { contributions, fetchContributionsError } = this.props;
let content;
if (contributions) {
if (contributions.top.length && contributions.latest.length) {
const makeContributionRow = (c: ContributionWithUser) => (
<div className="ProposalContributors-block-contributor" key={c.id}>
<UserRow
user={c.user}
extra={<>+<UnitDisplay value={toZat(c.amount)} symbol="ZEC" /></>}
/>
</div>
)
content = <>
<div className="ProposalContributors-block">
<h3 className="ProposalContributors-block-title">Latest contributors</h3>
{contributions.latest.map(makeContributionRow)}
</div>
<div className="ProposalContributors-block">
<h3 className="ProposalContributors-block-title">Top contributors</h3>
{contributions.top.map(makeContributionRow)}
</div>
</>;
} else {
content = (
<Placeholder
style={{ minHeight: '220px' }}
title="No contributors found"
subtitle={`
No contributions have been made to this proposal.
Check back later once there's been at least one contribution.
`}
/>
);
}
} else if (fetchContributionsError) {
content = <Placeholder title="Something went wrong" subtitle={fetchContributionsError} />;
} else {
content = <Spin />;
}
return (
<div className="ProposalContributors">
{content}
</div>
);
}
};
export default ContributorsBlock;
export default connect(
(state: AppState, ownProps: OwnProps) => ({
contributions: getProposalContributions(state, ownProps.proposalId),
isFetchingContributions: getIsFetchingContributions(state),
fetchContributionsError: getFetchContributionsError(state),
}),
{
fetchProposalContributions,
},
)(ProposalContributors);

View File

@ -16,7 +16,6 @@ import Milestones from './Milestones';
import CommentsTab from './Comments';
import UpdatesTab from './Updates';
import ContributorsTab from './Contributors';
// import CommunityTab from './Community';
import UpdateModal from './UpdateModal';
import CancelModal from './CancelModal';
import classnames from 'classnames';
@ -244,8 +243,8 @@ export class ProposalDetail extends React.Component<Props, State> {
<Tabs.TabPane tab="Updates" key="updates" disabled={!isLive}>
<UpdatesTab proposalId={proposal.proposalId} />
</Tabs.TabPane>
<Tabs.TabPane tab="Contributors" key="contributors" disabled={!isLive}>
<ContributorsTab />
<Tabs.TabPane tab="Contributors" key="contributors">
<ContributorsTab proposalId={proposal.proposalId} />
</Tabs.TabPane>
</Tabs>

View File

@ -6,9 +6,10 @@ import './style.less';
interface Props {
user: User;
extra?: React.ReactNode;
}
const UserRow = ({ user }: Props) => (
const UserRow = ({ user, extra }: Props) => (
<Link to={`/profile/${user.userid}`} className="UserRow">
<div className="UserRow-avatar">
<UserAvatar user={user} className="UserRow-avatar-img" />
@ -17,6 +18,11 @@ const UserRow = ({ user }: Props) => (
<div className="UserRow-info-main">{user.displayName}</div>
<p className="UserRow-info-secondary">{user.title}</p>
</div>
{extra && (
<div className="UserRow-extra">
{extra}
</div>
)}
</Link>
);

View File

@ -3,6 +3,7 @@
.UserRow {
position: relative;
display: flex;
align-items: center;
height: @height;
margin-bottom: 1rem;
color: inherit;
@ -40,9 +41,16 @@
font-size: 0.9rem;
opacity: 0.7;
min-width: 0;
margin: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
&-extra {
text-align: right;
margin-left: 1rem;
font-weight: bold;
}
}

View File

@ -4,6 +4,7 @@ import {
getProposal,
getProposalComments,
getProposalUpdates,
getProposalContributions,
postProposalComment as apiPostProposalComment,
} from 'api/api';
import { Dispatch } from 'redux';
@ -54,6 +55,18 @@ export function fetchProposalUpdates(proposalId: Proposal['proposalId']) {
};
}
export function fetchProposalContributions(proposalId: Proposal['proposalId']) {
return (dispatch: Dispatch<any>) => {
dispatch({
type: types.PROPOSAL_CONTRIBUTIONS,
payload: getProposalContributions(proposalId).then(res => ({
proposalId,
...res.data,
})),
});
};
}
export function postProposalComment(
proposalId: Proposal['proposalId'],
comment: string,

View File

@ -1,6 +1,6 @@
import types from './types';
import { findComment } from 'utils/helpers';
import { Proposal, ProposalComments, ProposalUpdates, Comment } from 'types';
import { Proposal, ProposalComments, ProposalUpdates, Comment, ProposalContributions } from 'types';
export interface ProposalState {
proposals: Proposal[];
@ -15,8 +15,15 @@ export interface ProposalState {
updatesError: null | string;
isFetchingUpdates: boolean;
proposalContributions: { [id: string]: ProposalContributions };
fetchContributionsError: null | string;
isFetchingContributions: boolean;
isPostCommentPending: boolean;
postCommentError: null | string;
isDeletingContribution: boolean;
deleteContributionError: null | string;
}
export const INITIAL_STATE: ProposalState = {
@ -32,8 +39,15 @@ export const INITIAL_STATE: ProposalState = {
updatesError: null,
isFetchingUpdates: false,
proposalContributions: {},
fetchContributionsError: null,
isFetchingContributions: false,
isPostCommentPending: false,
postCommentError: null,
isDeletingContribution: false,
deleteContributionError: null,
};
function addProposal(state: ProposalState, payload: Proposal) {
@ -89,6 +103,17 @@ function addUpdates(state: ProposalState, payload: ProposalUpdates) {
};
}
function addContributions(state: ProposalState, payload: ProposalContributions) {
return {
...state,
proposalContributions: {
...state.proposalContributions,
[payload.proposalId]: payload,
},
isFetchingContributions: false,
};
}
interface PostCommentPayload {
proposalId: Proposal['proposalId'];
comment: Comment;
@ -184,6 +209,22 @@ export default (state = INITIAL_STATE, action: any) => {
isFetchingUpdates: false,
};
case types.PROPOSAL_CONTRIBUTIONS_PENDING:
return {
...state,
fetchContributionsError: null,
isFetchingContributions: true,
};
case types.PROPOSAL_CONTRIBUTIONS_FULFILLED:
return addContributions(state, payload);
case types.PROPOSAL_CONTRIBUTIONS_REJECTED:
return {
...state,
// TODO: Get action to send real error
fetchContributionsError: 'Failed to fetch updates',
isFetchingContributions: false,
};
case types.POST_PROPOSAL_COMMENT_PENDING:
return {
...state,

View File

@ -1,5 +1,5 @@
import { AppState } from 'store/reducers';
import { Proposal, ProposalComments, ProposalUpdates } from 'types';
import { Proposal, ProposalComments, ProposalUpdates, ProposalContributions } from 'types';
export function getProposals(state: AppState) {
return state.proposal.proposals;
@ -74,3 +74,19 @@ export function getIsFetchingUpdates(state: AppState) {
export function getUpdatesError(state: AppState) {
return state.proposal.updatesError;
}
export function getProposalContributions(
state: AppState,
proposalId: Proposal['proposalId'],
): Omit<ProposalContributions, 'proposalId'> | null {
const pc = state.proposal.proposalContributions[proposalId];
return pc ? { top: pc.top, latest: pc.latest } : null;
}
export function getIsFetchingContributions(state: AppState) {
return state.proposal.isFetchingContributions;
}
export function getFetchContributionsError(state: AppState) {
return state.proposal.fetchContributionsError;
}

View File

@ -19,6 +19,11 @@ enum proposalTypes {
PROPOSAL_UPDATES_REJECTED = 'PROPOSAL_UPDATES_REJECTED',
PROPOSAL_UPDATES_PENDING = 'PROPOSAL_UPDATES_PENDING',
PROPOSAL_CONTRIBUTIONS = 'PROPOSAL_CONTRIBUTIONS',
PROPOSAL_CONTRIBUTIONS_FULFILLED = 'PROPOSAL_CONTRIBUTIONS_FULFILLED',
PROPOSAL_CONTRIBUTIONS_REJECTED = 'PROPOSAL_CONTRIBUTIONS_REJECTED',
PROPOSAL_CONTRIBUTIONS_PENDING = 'PROPOSAL_CONTRIBUTIONS_PENDING',
POST_PROPOSAL_COMMENT = 'POST_PROPOSAL_COMMENT',
POST_PROPOSAL_COMMENT_FULFILLED = 'POST_PROPOSAL_COMMENT_FULFILLED',
POST_PROPOSAL_COMMENT_REJECTED = 'POST_PROPOSAL_COMMENT_REJECTED',

View File

@ -5,6 +5,7 @@ import {
updateUser as apiUpdateUser,
fetchUserInvites as apiFetchUserInvites,
putInviteResponse,
deleteProposalContribution,
deleteProposalDraft,
putProposalPublish,
} from 'api/api';
@ -92,6 +93,18 @@ export function respondToInvite(
};
}
export function deleteContribution(userId: string | number, contributionId: string | number) {
// Fire and forget
deleteProposalContribution(contributionId);
return {
type: types.DELETE_CONTRIBUTION,
payload: {
userId,
contributionId,
},
};
}
export function deletePendingProposal(userId: number, proposalId: number) {
return async (dispatch: Dispatch<any>) => {
await dispatch({

View File

@ -1,7 +1,6 @@
import lodash from 'lodash';
import { UserProposal, UserComment, TeamInviteWithProposal } from 'types';
import { User, UserProposal, UserComment, UserContribution, TeamInviteWithProposal } from 'types';
import types from './types';
import { User } from 'types';
export interface TeamInviteWithResponse extends TeamInviteWithProposal {
isResponding: boolean;
@ -15,8 +14,8 @@ export interface UserState extends User {
isUpdating: boolean;
updateError: string | null;
pendingProposals: UserProposal[];
createdProposals: UserProposal[];
fundedProposals: UserProposal[];
proposals: UserProposal[];
contributions: UserContribution[];
comments: UserComment[];
isFetchingInvites: boolean;
hasFetchedInvites: boolean;
@ -45,8 +44,8 @@ export const INITIAL_USER_STATE: UserState = {
isUpdating: false,
updateError: null,
pendingProposals: [],
createdProposals: [],
fundedProposals: [],
proposals: [],
contributions: [],
comments: [],
isFetchingInvites: false,
hasFetchedInvites: false,
@ -150,6 +149,13 @@ export default (state = INITIAL_STATE, action: any) => {
isResponding: false,
respondError: errorMessage,
});
// delete contribution
case types.DELETE_CONTRIBUTION:
return updateUserState(state, payload.userId, {
contributions: state.map[payload.userId].contributions.filter(
c => c.id !== payload.contributionId
),
});
// proposal delete
case types.USER_DELETE_PROPOSAL_FULFILLED:
return removePendingProposal(state, payload.userId, payload.proposalId);
@ -198,7 +204,7 @@ function updatePublishedProposal(
) {
const withoutPending = removePendingProposal(state, userId, proposal.proposalId);
const userUpdates = {
createdProposals: [proposal, ...state.map[userId].createdProposals],
proposals: [proposal, ...state.map[userId].proposals],
};
return updateUserState(withoutPending, userId, userUpdates);
}

View File

@ -19,6 +19,8 @@ enum UsersActions {
RESPOND_TO_INVITE_FULFILLED = 'RESPOND_TO_INVITE_FULFILLED',
RESPOND_TO_INVITE_REJECTED = 'RESPOND_TO_INVITE_REJECTED',
DELETE_CONTRIBUTION = 'DELETE_CONTRIBUTION',
USER_DELETE_PROPOSAL = 'USER_DELETE_PROPOSAL',
USER_DELETE_PROPOSAL_FULFILLED = 'USER_DELETE_PROPOSAL_FULFILLED',

View File

@ -20,8 +20,11 @@ export function formatUserFromGet(user: UserState) {
if (user.pendingProposals) {
user.pendingProposals = user.pendingProposals.map(bnUserProp);
}
user.createdProposals = user.createdProposals.map(bnUserProp);
user.fundedProposals = user.fundedProposals.map(bnUserProp);
user.proposals = user.proposals.map(bnUserProp);
user.contributions = user.contributions.map(c => {
c.amount = toZat(c.amount as any as string);
return c;
});
return user;
}
@ -30,17 +33,21 @@ export function formatProposalFromGet(p: any): Proposal {
proposal.proposalUrlId = generateProposalUrl(proposal.proposalId, proposal.title);
proposal.target = toZat(p.target);
proposal.funded = toZat(p.funded);
proposal.percentFunded = proposal.funded.div(proposal.target.divn(100)).toNumber();
proposal.milestones = proposal.milestones.map((m: any, index: number) => {
return {
...m,
index,
amount: proposal.target.mul(new BN(m.payoutPercent)).divn(100),
// TODO: Get data from backend
state: MILESTONE_STATE.WAITING,
isPaid: false,
};
});
proposal.percentFunded = proposal.target.isZero()
? 0
: proposal.funded.div(proposal.target.divn(100)).toNumber();
if (proposal.milestones) {
proposal.milestones = proposal.milestones.map((m: any, index: number) => {
return {
...m,
index,
amount: proposal.target.mul(new BN(m.payoutPercent)).divn(100),
// TODO: Get data from backend
state: MILESTONE_STATE.WAITING,
isPaid: false,
};
});
}
return proposal;
}
@ -83,10 +90,14 @@ export function massageSerializedState(state: AppState) {
return p;
};
Object.values(state.users.map).forEach(user => {
user.createdProposals.forEach(bnUserProp);
user.fundedProposals.forEach(bnUserProp);
user.comments.forEach(c => {
user.proposals = user.proposals.map(bnUserProp);
user.contributions = user.contributions.map(c => {
c.amount = new BN(c.amount, 16);
return c;
});
user.comments = user.comments.map(c => {
c.proposal = bnUserProp(c.proposal);
return c;
});
});

View File

@ -85,3 +85,7 @@ export function formatZcashCLI(address: string, amount?: string | number, memo?:
}
return `zcash-cli z_sendmany YOUR_ADDRESS '${JSON.stringify([tx])}'`;
}
export function formatTxExplorerUrl(txid: string) {
return `https://explorer.zcha.in/transactions/${txid}`;
}

View File

@ -2,7 +2,6 @@ import * as React from 'react';
import { storiesOf } from '@storybook/react';
import { ProposalCampaignBlock } from 'components/Proposal/CampaignBlock';
import Contributors from 'components/Proposal/Contributors';
import 'styles/style.less';
import 'components/Proposal/style.less';
@ -53,9 +52,4 @@ storiesOf('Proposal', module)
<div style={{ display: 'flex', flexWrap: 'wrap' }}>
<CampaignBlocks style={{ margin: '0 12px' }} />
</div>
))
.add('Contributors', () => (
<div style={{ display: 'flex', justifyContent: 'center' }}>
<Contributors />
</div>
));

View File

@ -1,8 +1,12 @@
import { Zat } from 'utils/units';
import { Proposal, User } from 'types';
export interface Contribution {
id: string;
id: number;
txId: string;
amount: string;
dateCreated: number;
status: 'PENDING' | 'CONFIRMED';
}
export interface ContributionWithAddresses extends Contribution {
@ -12,3 +16,13 @@ export interface ContributionWithAddresses extends Contribution {
memo: string;
};
}
export interface ContributionWithUser extends Contribution {
user: User;
}
export interface UserContribution extends Omit<Contribution, 'amount' | 'txId'> {
amount: Zat;
txId?: string;
proposal: Proposal;
}

View File

@ -1,7 +1,6 @@
import BN from 'bn.js';
import { Zat } from 'utils/units';
import { PROPOSAL_CATEGORY } from 'api/constants';
import { CreateMilestone, Update, User, Comment } from 'types';
import { CreateMilestone, Update, User, Comment, ContributionWithUser } from 'types';
import { ProposalMilestone } from './milestone';
export interface TeamInvite {
@ -40,8 +39,8 @@ export interface ProposalDraft {
export interface Proposal extends Omit<ProposalDraft, 'target' | 'invites'> {
proposalAddress: string;
proposalUrlId: string;
target: BN;
funded: BN;
target: Zat;
funded: Zat;
percentFunded: number;
milestones: ProposalMilestone[];
datePublished: number;
@ -62,13 +61,19 @@ export interface ProposalUpdates {
updates: Update[];
}
export interface ProposalContributions {
proposalId: Proposal['proposalId'];
top: ContributionWithUser[];
latest: ContributionWithUser[];
}
export interface UserProposal {
proposalId: number;
status: STATUS;
title: string;
brief: string;
funded: BN;
target: BN;
funded: Zat;
target: Zat;
dateCreated: number;
dateApproved: number;
datePublished: number;