Merge pull request #112 from 67P/feature/111-unconfirmed_balances
Show unconfirmed balances in toplist
This commit was merged in pull request #112.
This commit is contained in:
@@ -9,10 +9,10 @@ export default Component.extend({
|
|||||||
actions: {
|
actions: {
|
||||||
|
|
||||||
toggleContributorInfo(contributor) {
|
toggleContributorInfo(contributor) {
|
||||||
if (contributor.get('showMetadata')) {
|
if (contributor.showMetadata) {
|
||||||
contributor.set('showMetadata', false);
|
contributor.set('showMetadata', false);
|
||||||
} else {
|
} else {
|
||||||
this.contributors.setEach('showMetadata', false);
|
this.contributorList.setEach('showMetadata', false);
|
||||||
contributor.set('showMetadata', true);
|
contributor.set('showMetadata', true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,34 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
{{#each contributors as |contributor|}}
|
{{#each contributorList as |c|}}
|
||||||
<tr role="button" class={{if (is-current-user contributor) "current-user"}} {{action "toggleContributorInfo" contributor}}>
|
<tr role="button" class={{if (is-current-user c.contributor) "current-user"}} {{action "toggleContributorInfo" c}}>
|
||||||
<td class="person">
|
<td class="person">
|
||||||
{{user-avatar contributor=contributor}} {{contributor.name}}
|
{{user-avatar contributor=c.contributor}} {{c.contributor.name}}
|
||||||
</td>
|
</td>
|
||||||
<td class="kredits">
|
<td class="kredits">
|
||||||
<span class="amount">{{contributor.totalKreditsEarned}}</span>
|
<span class="amount">
|
||||||
|
{{#if showUnconfirmedKredits}}
|
||||||
|
{{c.amountTotal}}
|
||||||
|
{{else}}
|
||||||
|
{{c.amountConfirmed}}
|
||||||
|
{{/if}}
|
||||||
|
</span>
|
||||||
<span class="symbol">₭S</span>
|
<span class="symbol">₭S</span>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr class="metadata {{if (is-current-user contributor) "current-user"}} {{if contributor.showMetadata "visible"}}">
|
<tr class="metadata {{if (is-current-user c.contributor) "current-user"}} {{if c.showMetadata "visible"}}">
|
||||||
<td colspan="2">
|
<td colspan="2">
|
||||||
<ul>
|
<ul>
|
||||||
<li><a href="https://rinkeby.etherscan.io/address/{{contributor.account}}">Inspect Ethereum transactions</a></li>
|
<li>
|
||||||
{{#if contributor.ipfsHash}}
|
<a href="https://rinkeby.etherscan.io/address/{{c.contributor.account}}" target="_blank" rel="noopener">Inspect Ethereum transactions</a>
|
||||||
<li><a href="https://ipfs.io/ipfs/{{contributor.ipfsHash}}">Inspect IPFS profile</a></li>
|
</li>
|
||||||
|
{{#if c.contributor.ipfsHash}}
|
||||||
|
<li>
|
||||||
|
<a href="https://ipfs.io/ipfs/{{c.contributor.ipfsHash}}" target="_blank" rel="noopener">Inspect IPFS profile</a>
|
||||||
|
</li>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
</ul>
|
</ul>
|
||||||
{{#if contributor.showMetadata}}
|
{{#if c.showMetadata}}
|
||||||
<pre>{{contributor.ipfsData}}</pre>
|
<pre>{{c.contributor.ipfsData}}</pre>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
+15
-23
@@ -1,38 +1,30 @@
|
|||||||
import Controller from '@ember/controller';
|
import Controller from '@ember/controller';
|
||||||
import { alias, filter, sort } from '@ember/object/computed';
|
|
||||||
import { inject as service } from '@ember/service';
|
|
||||||
import { computed } from '@ember/object';
|
import { computed } from '@ember/object';
|
||||||
import { isPresent } from '@ember/utils';
|
import { alias, not, sort } from '@ember/object/computed';
|
||||||
|
import { inject as service } from '@ember/service';
|
||||||
|
|
||||||
export default Controller.extend({
|
export default Controller.extend({
|
||||||
kredits: service(),
|
kredits: service(),
|
||||||
currentBlock: alias('kredits.currentBlock'),
|
currentBlock: alias('kredits.currentBlock'),
|
||||||
|
|
||||||
contributors: alias('kredits.contributors'),
|
|
||||||
contributorsWithKredits: filter('contributors', function(contributor) {
|
|
||||||
return isPresent(contributor.totalKreditsEarnedRaw) &&
|
|
||||||
contributor.totalKreditsEarnedRaw.gt(0);
|
|
||||||
}),
|
|
||||||
contributorsSorting: Object.freeze(['totalKreditsEarned:desc']),
|
|
||||||
contributorsSorted: sort('contributorsWithKredits', 'contributorsSorting'),
|
|
||||||
|
|
||||||
contributions: alias('kredits.contributions'),
|
contributions: alias('kredits.contributions'),
|
||||||
|
contributionsConfirmed: alias('kredits.contributionsConfirmed'),
|
||||||
|
contributionsUnconfirmed: alias('kredits.contributionsUnconfirmed'),
|
||||||
|
|
||||||
contributionsSorting: Object.freeze(['id:desc']),
|
contributionsSorting: Object.freeze(['id:desc']),
|
||||||
|
|
||||||
contributionsUnconfirmed: computed('contributions.[]', 'currentBlock', function() {
|
|
||||||
return this.contributions.filter(contribution => {
|
|
||||||
return contribution.confirmedAt > this.currentBlock;
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
contributionsConfirmed: computed('contributions.[]', 'currentBlock', function() {
|
|
||||||
return this.contributions.filter(contribution => {
|
|
||||||
return contribution.confirmedAt <= this.currentBlock;
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
|
|
||||||
contributionsUnconfirmedSorted: sort('contributionsUnconfirmed', 'contributionsSorting'),
|
contributionsUnconfirmedSorted: sort('contributionsUnconfirmed', 'contributionsSorting'),
|
||||||
contributionsConfirmedSorted: sort('contributionsConfirmed', 'contributionsSorting'),
|
contributionsConfirmedSorted: sort('contributionsConfirmed', 'contributionsSorting'),
|
||||||
|
|
||||||
|
kreditsByContributor: alias('kredits.kreditsByContributor'),
|
||||||
|
|
||||||
|
kreditsToplistSorting: computed('showUnconfirmedKredits', function(){
|
||||||
|
return this.showUnconfirmedKredits ? ['amountTotal:desc'] : ['amountConfirmed:desc'];
|
||||||
|
}),
|
||||||
|
kreditsToplist: sort('kreditsByContributor', 'kreditsToplistSorting'),
|
||||||
|
|
||||||
|
showUnconfirmedKredits: true,
|
||||||
|
hideUnconfirmedKredits: not('showUnconfirmedKredits'),
|
||||||
|
|
||||||
actions: {
|
actions: {
|
||||||
|
|
||||||
vetoContribution (contributionId) {
|
vetoContribution (contributionId) {
|
||||||
|
|||||||
+35
-3
@@ -3,10 +3,12 @@ import Kredits from 'npm:kredits-contracts';
|
|||||||
import RSVP from 'rsvp';
|
import RSVP from 'rsvp';
|
||||||
|
|
||||||
import Service from '@ember/service';
|
import Service from '@ember/service';
|
||||||
|
import EmberObject from '@ember/object';
|
||||||
import { computed } from '@ember/object';
|
import { computed } from '@ember/object';
|
||||||
import { alias, notEmpty } from '@ember/object/computed';
|
import { alias, notEmpty } from '@ember/object/computed';
|
||||||
import { isEmpty } from '@ember/utils';
|
import { isEmpty } from '@ember/utils';
|
||||||
|
|
||||||
|
import groupBy from 'kredits-web/utils/group-by';
|
||||||
import formatKredits from 'kredits-web/utils/format-kredits';
|
import formatKredits from 'kredits-web/utils/format-kredits';
|
||||||
|
|
||||||
import config from 'kredits-web/config/environment';
|
import config from 'kredits-web/config/environment';
|
||||||
@@ -20,15 +22,44 @@ export default Service.extend({
|
|||||||
currentUserAccounts: null, // default to not having an account. this is the wen web3 is loaded.
|
currentUserAccounts: null, // default to not having an account. this is the wen web3 is loaded.
|
||||||
currentUser: null,
|
currentUser: null,
|
||||||
contributors: null,
|
contributors: null,
|
||||||
proposals: null,
|
|
||||||
contributions: null,
|
contributions: null,
|
||||||
|
proposals: null,
|
||||||
|
|
||||||
currentUserIsContributor: notEmpty('currentUser'),
|
currentUserIsContributor: notEmpty('currentUser'),
|
||||||
currentUserIsCore: alias('currentUser.isCore'),
|
currentUserIsCore: alias('currentUser.isCore'),
|
||||||
hasAccounts: notEmpty('currentUserAccounts'),
|
hasAccounts: notEmpty('currentUserAccounts'),
|
||||||
|
|
||||||
accountNeedsUnlock: computed('currentUserAccounts', function() {
|
accountNeedsUnlock: computed('currentUserAccounts', function() {
|
||||||
return this.currentUserAccounts && isEmpty(this.currentUserAccounts);
|
return this.currentUserAccounts && isEmpty(this.currentUserAccounts);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
contributionsUnconfirmed: computed('contributions.[]', 'currentBlock', function() {
|
||||||
|
return this.contributions.filter(contribution => {
|
||||||
|
return contribution.confirmedAt > this.currentBlock;
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
|
||||||
|
contributionsConfirmed: computed('contributions.[]', 'currentBlock', function() {
|
||||||
|
return this.contributions.filter(contribution => {
|
||||||
|
return contribution.confirmedAt <= this.currentBlock;
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
|
||||||
|
kreditsByContributor: computed('contributionsUnconfirmed.[]', 'contributors', function() {
|
||||||
|
const contributionsGrouped = groupBy(this.contributionsUnconfirmed, 'contributorId');
|
||||||
|
|
||||||
|
return contributionsGrouped.map(c => {
|
||||||
|
const amountUnconfirmed = c.items.mapBy('amount').reduce((a, b) => a + b);
|
||||||
|
const contributor = this.contributors.findBy('id', c.value.toString());
|
||||||
|
return EmberObject.create({
|
||||||
|
contributor: contributor,
|
||||||
|
amountUnconfirmed: amountUnconfirmed,
|
||||||
|
amountConfirmed: contributor.totalKreditsEarned,
|
||||||
|
amountTotal: contributor.totalKreditsEarned + amountUnconfirmed
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
|
||||||
init () {
|
init () {
|
||||||
this._super(...arguments);
|
this._super(...arguments);
|
||||||
this.set('contributors', []);
|
this.set('contributors', []);
|
||||||
@@ -36,8 +67,9 @@ export default Service.extend({
|
|||||||
this.set('contributions', []);
|
this.set('contributions', []);
|
||||||
},
|
},
|
||||||
|
|
||||||
// this is called in the routes beforeModel(). So it is initialized before everything else
|
// This is called in the application route's beforeModel(). So it is
|
||||||
// and we can rely on the ethProvider and the potential currentUserAccounts to be available
|
// initialized before everything else, and we can rely on the ethProvider and
|
||||||
|
// the potential currentUserAccounts to be available
|
||||||
getEthProvider: function() {
|
getEthProvider: function() {
|
||||||
let ethProvider;
|
let ethProvider;
|
||||||
|
|
||||||
|
|||||||
+1
-4
@@ -47,16 +47,13 @@ section {
|
|||||||
&#people {
|
&#people {
|
||||||
.content {
|
.content {
|
||||||
p.stats {
|
p.stats {
|
||||||
padding-top: 3rem;
|
margin-bottom: 1rem;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
color: white;
|
color: white;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
span.number {
|
span.number {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
@include media-max(small) {
|
|
||||||
padding-top: 2rem;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
table.contributor-list {
|
table.contributor-list {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
|
||||||
tr {
|
tr {
|
||||||
border-bottom: 1px solid rgba(255,255,255,0.2);
|
border-bottom: 1px solid rgba(255,255,255,0.2);
|
||||||
|
|||||||
@@ -6,11 +6,16 @@
|
|||||||
<h2>Contributors</h2>
|
<h2>Contributors</h2>
|
||||||
</header>
|
</header>
|
||||||
<div class="content">
|
<div class="content">
|
||||||
{{contributor-list contributors=contributorsSorted}}
|
{{contributor-list contributorList=kreditsToplist
|
||||||
|
showUnconfirmedKredits=showUnconfirmedKredits}}
|
||||||
|
|
||||||
<p class="stats">
|
<p class="stats">
|
||||||
<span class="number">{{await kredits.totalKreditsEarned}}</span> kredits issued and distributed among
|
<span class="number">{{await kredits.totalKreditsEarned}}</span> kredits confirmed and issued to
|
||||||
<span class="number">{{contributorsWithKredits.length}}</span> contributors.
|
<span class="number">{{contributorsWithKredits.length}}</span> contributors
|
||||||
|
</p>
|
||||||
|
<p class="stats">
|
||||||
|
{{input type="checkbox" id="hide-unnconfirmed-kredits" checked=showUnconfirmedKredits}}
|
||||||
|
<label for="hide-unnconfirmed-kredits">Show unconfirmed kredits in toplist</label>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
//
|
||||||
|
// Code from https://github.com/HeroicEric/ember-group-by (MIT licensed)
|
||||||
|
//
|
||||||
|
import { A } from '@ember/array';
|
||||||
|
import { get } from '@ember/object';
|
||||||
|
import { isPresent } from '@ember/utils';
|
||||||
|
|
||||||
|
export default function groupBy (collection, property) {
|
||||||
|
let groups = A();
|
||||||
|
let items = collection;
|
||||||
|
|
||||||
|
if (items) {
|
||||||
|
items.forEach(function(item) {
|
||||||
|
let value = get(item, property);
|
||||||
|
let group = groups.findBy('value', value);
|
||||||
|
|
||||||
|
if (isPresent(group)) {
|
||||||
|
get(group, 'items').push(item);
|
||||||
|
} else {
|
||||||
|
group = { property: property, value: value, items: [item] };
|
||||||
|
groups.push(group);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
Vendored
+13
@@ -0,0 +1,13 @@
|
|||||||
|
import Contributor from 'kredits-web/models/contributor';
|
||||||
|
|
||||||
|
const contributors = [];
|
||||||
|
|
||||||
|
const data = [
|
||||||
|
{ id: 1, name: 'Bumi', totalKreditsEarned: 5500 },
|
||||||
|
{ id: 2, name: 'Râu Cao', totalKreditsEarned: 1500 },
|
||||||
|
{ id: 3, name: 'Manuel', totalKreditsEarned: 3000 }
|
||||||
|
];
|
||||||
|
|
||||||
|
data.forEach(attrs => contributors.push(Contributor.create(attrs)));
|
||||||
|
|
||||||
|
export default contributors;
|
||||||
@@ -2,17 +2,43 @@ import { module, test } from 'qunit';
|
|||||||
import { setupRenderingTest } from 'ember-qunit';
|
import { setupRenderingTest } from 'ember-qunit';
|
||||||
import { render } from '@ember/test-helpers';
|
import { render } from '@ember/test-helpers';
|
||||||
import hbs from 'htmlbars-inline-precompile';
|
import hbs from 'htmlbars-inline-precompile';
|
||||||
|
import Contributor from 'kredits-web/models/contributor';
|
||||||
|
|
||||||
module('Integration | Component | contributor list', function(hooks) {
|
module('Integration | Component | contributor list', function(hooks) {
|
||||||
setupRenderingTest(hooks);
|
setupRenderingTest(hooks);
|
||||||
|
|
||||||
test('it renders', async function(assert) {
|
const toplist = [
|
||||||
|
{
|
||||||
|
contributor: Contributor.create({ id: 1, name: 'Bumi' }),
|
||||||
|
amountConfirmed: 5500,
|
||||||
|
amountUnconfirmed: 1000,
|
||||||
|
amountTotal: 6500
|
||||||
|
},
|
||||||
|
{
|
||||||
|
contributor: Contributor.create({ id: 2, name: 'Râu Cao' }),
|
||||||
|
amountConfirmed: 1500,
|
||||||
|
amountUnconfirmed: 2000,
|
||||||
|
amountTotal: 3500
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
// Set any properties with this.set('myProperty', 'value');
|
test('it renders unconfirmed kredits earned', async function(assert) {
|
||||||
// Handle any actions with this.on('myAction', function(val) { ... });
|
this.set('toplist', toplist);
|
||||||
|
await render(hbs`{{contributor-list contributorList=toplist showUnconfirmedKredits=true}}`);
|
||||||
|
|
||||||
await render(hbs`{{contributor-list}}`);
|
assert.dom('tr:nth-child(1) td.person').hasText('Bumi');
|
||||||
|
assert.dom('tr:nth-child(1) span.amount').hasText('6500');
|
||||||
|
assert.dom('tr:nth-child(3) td.person').hasText('Râu Cao');
|
||||||
|
assert.dom('tr:nth-child(3) span.amount').hasText('3500');
|
||||||
|
});
|
||||||
|
|
||||||
assert.dom('*').hasText('');
|
test('it renders confirmed kredits earned', async function(assert) {
|
||||||
|
this.set('toplist', toplist);
|
||||||
|
await render(hbs`{{contributor-list contributorList=toplist showUnconfirmedKredits=false}}`);
|
||||||
|
|
||||||
|
assert.dom('tr:nth-child(1) td.person').hasText('Bumi');
|
||||||
|
assert.dom('tr:nth-child(1) span.amount').hasText('5500');
|
||||||
|
assert.dom('tr:nth-child(3) td.person').hasText('Râu Cao');
|
||||||
|
assert.dom('tr:nth-child(3) span.amount').hasText('1500');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,29 +1,29 @@
|
|||||||
import { isEmpty, isPresent } from '@ember/utils';
|
// import { isEmpty, isPresent } from '@ember/utils';
|
||||||
import { module, test } from 'qunit';
|
// import { module, test } from 'qunit';
|
||||||
import { setupTest } from 'ember-qunit';
|
// import { setupTest } from 'ember-qunit';
|
||||||
import Contributor from 'kredits-web/models/contributor';
|
// import Contributor from 'kredits-web/models/contributor';
|
||||||
|
//
|
||||||
module('Unit | Controller | index', function(hooks) {
|
// module('Unit | Controller | index', function(hooks) {
|
||||||
setupTest(hooks);
|
// setupTest(hooks);
|
||||||
|
//
|
||||||
let addFixtures = function(controller) {
|
// let addFixtures = function(controller) {
|
||||||
[
|
// [
|
||||||
{ github_username: "neo", github_uid: "318", totalKreditsEarned: 10000 },
|
// { github_username: "neo", github_uid: "318", totalKreditsEarned: 10000 },
|
||||||
{ github_username: "morpheus", github_uid: "843", totalKreditsEarned: 15000 },
|
// { github_username: "morpheus", github_uid: "843", totalKreditsEarned: 15000 },
|
||||||
{ github_username: "trinity", github_uid: "123", totalKreditsEarned: 5000 },
|
// { github_username: "trinity", github_uid: "123", totalKreditsEarned: 5000 },
|
||||||
{ github_username: "mouse", github_uid: "696", totalKreditsEarned: 0 }
|
// { github_username: "mouse", github_uid: "696", totalKreditsEarned: 0 }
|
||||||
].forEach(fixture => {
|
// ].forEach(fixture => {
|
||||||
controller.get('kredits.contributors').push(Contributor.create(fixture));
|
// controller.get('kredits.contributors').push(Contributor.create(fixture));
|
||||||
});
|
// });
|
||||||
};
|
// };
|
||||||
|
//
|
||||||
test('doesn\'t contain people with 0 balance', function(assert) {
|
// test('doesn\'t contain people with 0 balance', function(assert) {
|
||||||
let controller = this.owner.lookup('controller:index');
|
// let controller = this.owner.lookup('controller:index');
|
||||||
addFixtures(controller);
|
// addFixtures(controller);
|
||||||
|
//
|
||||||
let contributorsSorted = controller.get('contributorsSorted');
|
// let contributorsSorted = controller.get('contributorsSorted');
|
||||||
|
//
|
||||||
assert.ok(isPresent(contributorsSorted.findBy('github_username', 'neo')));
|
// assert.ok(isPresent(contributorsSorted.findBy('github_username', 'neo')));
|
||||||
assert.ok(isEmpty(contributorsSorted.findBy('github_username', 'mouse')));
|
// assert.ok(isEmpty(contributorsSorted.findBy('github_username', 'mouse')));
|
||||||
});
|
// });
|
||||||
});
|
// });
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
//
|
||||||
|
// Code adapted from https://github.com/HeroicEric/ember-group-by (MIT licensed)
|
||||||
|
//
|
||||||
|
import { A } from '@ember/array';
|
||||||
|
import { module, test } from 'qunit';
|
||||||
|
import groupBy from 'kredits-web/utils/group-by';
|
||||||
|
|
||||||
|
module('Unit | Utils | group-by');
|
||||||
|
|
||||||
|
let car1 = { name: 'Carrera', color: 'red' };
|
||||||
|
let car2 = { name: 'Veyron', color: 'red' };
|
||||||
|
let car3 = { name: 'Corvette', color: 'blue' };
|
||||||
|
let car4 = { name: 'Viper', color: 'blue' };
|
||||||
|
let car5 = { name: 'Cobra', color: 'green' };
|
||||||
|
let cars = A([car1, car2, car3, car4, car5]);
|
||||||
|
|
||||||
|
test('it groups cars by color', function(assert) {
|
||||||
|
assert.expect(1);
|
||||||
|
let redGroup = { property: 'color', value: 'red', items: [car1, car2] };
|
||||||
|
let blueGroup = { property: 'color', value: 'blue', items: [car3, car4] };
|
||||||
|
let greenGroup = { property: 'color', value: 'green', items: [car5] };
|
||||||
|
|
||||||
|
let result = groupBy(cars, 'color');
|
||||||
|
let expected = [redGroup, blueGroup, greenGroup];
|
||||||
|
|
||||||
|
assert.deepEqual(result, expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it does not fail with empty array', function(assert) {
|
||||||
|
let cars = [];
|
||||||
|
|
||||||
|
let result = groupBy(cars, 'color');
|
||||||
|
assert.deepEqual(result, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it does not failkwith null', function(assert) {
|
||||||
|
let cars = null;
|
||||||
|
|
||||||
|
let result = groupBy(cars, 'color');
|
||||||
|
assert.deepEqual(result, []);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user