Merge branch 'master' into fix-ignore-user

This commit is contained in:
Benjamin Goering
2017-05-17 13:57:23 -07:00
committed by GitHub
26 changed files with 740 additions and 48 deletions
+5 -3
View File
@@ -69,9 +69,11 @@ app.use('/api/v1/graph/ql', apollo.graphqlExpress(createGraphOptions));
if (app.get('env') !== 'production') {
// Interactive graphiql interface.
app.use('/api/v1/graph/iql', apollo.graphiqlExpress({
endpointURL: '/api/v1/graph/ql'
}));
app.use('/api/v1/graph/iql', (req, res) => {
res.render('graphiql', {
endpointURL: '/api/v1/graph/ql'
});
});
// GraphQL documention.
app.get('/admin/docs', (req, res) => {
@@ -18,3 +18,6 @@ export const hideShortcutsNote = () => {
return {type: actions.HIDE_SHORTCUTS_NOTE};
};
export const viewUserDetail = (userId) => ({type: actions.VIEW_USER_DETAIL, userId});
export const hideUserDetail = () => ({type: actions.HIDE_USER_DETAIL});
@@ -3,3 +3,5 @@ export const SINGLE_VIEW = 'SINGLE_VIEW';
export const SHOW_BANUSER_DIALOG = 'SHOW_BANUSER_DIALOG';
export const HIDE_BANUSER_DIALOG = 'HIDE_BANUSER_DIALOG';
export const HIDE_SHORTCUTS_NOTE = 'HIDE_SHORTCUTS_NOTE';
export const VIEW_USER_DETAIL = 'VIEW_USER_DETAIL';
export const HIDE_USER_DETAIL = 'HIDE_USER_DETAIL';
@@ -10,7 +10,15 @@ import {banUser, setCommentStatus} from '../../graphql/mutations';
import {fetchSettings} from 'actions/settings';
import {updateAssets} from 'actions/assets';
import {toggleModal, singleView, showBanUserDialog, hideBanUserDialog, hideShortcutsNote} from 'actions/moderation';
import {
toggleModal,
singleView,
showBanUserDialog,
hideBanUserDialog,
hideShortcutsNote,
viewUserDetail,
hideUserDetail
} from 'actions/moderation';
import {Spinner} from 'coral-ui';
import BanUserDialog from '../../components/BanUserDialog';
@@ -19,6 +27,7 @@ import ModerationMenu from './components/ModerationMenu';
import ModerationHeader from './components/ModerationHeader';
import NotFoundAsset from './components/NotFoundAsset';
import ModerationKeysModal from '../../components/ModerationKeysModal';
import UserDetail from './UserDetail';
class ModerationContainer extends Component {
state = {
@@ -111,7 +120,7 @@ class ModerationContainer extends Component {
}
render () {
const {data, moderation, settings, assets, onClose, ...props} = this.props;
const {data, moderation, settings, assets, onClose, viewUserDetail, hideUserDetail, ...props} = this.props;
const providedAssetId = this.props.params.id;
const activeTab = this.props.route.path === ':id' ? 'premod' : this.props.route.path;
@@ -181,6 +190,8 @@ class ModerationContainer extends Component {
assetId={providedAssetId}
sort={this.state.sort}
commentCount={activeTabCount}
viewUserDetail={viewUserDetail}
hideUserDetail={hideUserDetail}
/>
<BanUserDialog
open={moderation.banDialog}
@@ -192,11 +203,16 @@ class ModerationContainer extends Component {
showRejectedNote={moderation.showRejectedNote}
rejectComment={props.rejectComment}
/>
<ModerationKeysModal
<ModerationKeysModal
hideShortcutsNote={props.hideShortcutsNote}
shortcutsNoteVisible={moderation.shortcutsNoteVisible}
open={moderation.modalOpen}
onClose={onClose}/>
{moderation.userDetailId && (
<UserDetail
id={moderation.userDetailId}
hideUserDetail={hideUserDetail} />
)}
</div>
);
}
@@ -214,6 +230,8 @@ const mapDispatchToProps = (dispatch) => ({
singleView: () => dispatch(singleView()),
updateAssets: (assets) => dispatch(updateAssets(assets)),
fetchSettings: () => dispatch(fetchSettings()),
viewUserDetail: (id) => dispatch(viewUserDetail(id)),
hideUserDetail: () => dispatch(hideUserDetail()),
showBanUserDialog: (user, commentId, commentStatus, showRejectedNote) => dispatch(showBanUserDialog(user, commentId, commentStatus, showRejectedNote)),
hideBanUserDialog: () => dispatch(hideBanUserDialog(false)),
hideShortcutsNote: () => dispatch(hideShortcutsNote()),
@@ -12,6 +12,7 @@ const lang = new I18n(translations);
class ModerationQueue extends React.Component {
static propTypes = {
viewUserDetail: PropTypes.func.isRequired,
bannedWords: PropTypes.arrayOf(PropTypes.string).isRequired,
suspectWords: PropTypes.arrayOf(PropTypes.string).isRequired,
currentAsset: PropTypes.object,
@@ -33,7 +34,17 @@ class ModerationQueue extends React.Component {
}
render () {
const {comments, selectedIndex, commentCount, singleView, loadMore, activeTab, sort, ...props} = this.props;
const {
comments,
selectedIndex,
commentCount,
singleView,
loadMore,
activeTab,
sort,
viewUserDetail,
...props
} = this.props;
return (
<div id="moderationList" className={`${styles.list} ${singleView ? styles.singleView : ''}`}>
@@ -49,6 +60,7 @@ class ModerationQueue extends React.Component {
selected={i === selectedIndex}
suspectWords={props.suspectWords}
bannedWords={props.bannedWords}
viewUserDetail={viewUserDetail}
actions={actionsMap[status]}
showBanUserDialog={props.showBanUserDialog}
acceptComment={props.acceptComment}
@@ -0,0 +1,32 @@
.copyButton {
float: right;
top: -10px;
}
.memberSince {
clear: both;
}
.small {
color: #aaa;
}
.stats {
display: flex;
.stat {
margin: 0 4px 12px;
}
.stat:last-child {
margin-right: 0;
}
p {
margin: 0;
}
.stat p:first-child {
font-weight: bold;
}
}
@@ -0,0 +1,74 @@
import React, {PropTypes} from 'react';
import {Button, Drawer} from 'coral-ui';
import styles from './UserDetail.css';
import {compose} from 'react-apollo';
import {getUserDetail} from 'coral-admin/src/graphql/queries';
import Slot from 'coral-framework/components/Slot';
class UserDetail extends React.Component {
static propTypes = {
id: PropTypes.string.isRequired,
hideUserDetail: PropTypes.func.isRequired
}
copyPermalink () {
this.profile.select();
try {
document.execCommand('copy');
} catch (e) {
/* nothing */
}
}
render () {
const {data, hideUserDetail} = this.props;
if (!('user' in data)) {
return null;
}
const {user, totalComments, rejectedComments} = data;
const localProfile = user.profiles.find((p) => p.provider === 'local');
let profile;
if (localProfile) {
profile = localProfile.id;
}
let rejectedPercent = rejectedComments / totalComments;
if (rejectedPercent === Infinity || isNaN(rejectedPercent)) {
// if totalComments is 0, you're dividing by zero, which is naughty
rejectedPercent = 0;
}
return (
<Drawer handleClickOutside={hideUserDetail}>
<h3>{user.username}</h3>
<Button className={styles.copyButton}>Copy</Button>
{profile && <p ref={(ref) => this.profile = ref} contentEditable="true">{profile}</p>}
<Slot fill="userProfile" user={user} />
<p className={styles.memberSince}><strong>Member since</strong> {new Date(user.created_at).toLocaleString()}</p>
<hr/>
<p>
<strong>Account summary</strong>
<br/><small className={styles.small}>Data represents the last six months of activity</small>
</p>
<div className={styles.stats}>
<div className={styles.stat}>
<p>Total Comments</p>
<p>{totalComments}</p>
</div>
<div className={styles.stat}>
<p>Reject Rate</p>
<p>{`${(rejectedPercent).toFixed(1)}%`}</p>
</div>
</div>
</Drawer>
);
}
}
export default compose(
getUserDetail
)(UserDetail);
@@ -22,6 +22,7 @@ const lang = new I18n(translations);
const Comment = ({
actions = [],
comment,
viewUserDetail,
suspectWords,
bannedWords,
...props
@@ -56,7 +57,7 @@ const Comment = ({
<div className={styles.container}>
<div className={styles.itemHeader}>
<div className={styles.author}>
<span>
<span className={styles.username} onClick={() => viewUserDetail(comment.user.id)}>
{comment.user.name}
</span>
<span className={styles.created}>
@@ -154,6 +155,7 @@ const Comment = ({
};
Comment.propTypes = {
viewUserDetail: PropTypes.func.isRequired,
acceptComment: PropTypes.func.isRequired,
rejectComment: PropTypes.func.isRequired,
suspectWords: PropTypes.arrayOf(PropTypes.string).isRequired,
@@ -165,8 +167,9 @@ Comment.propTypes = {
actions: PropTypes.array,
created_at: PropTypes.string.isRequired,
user: PropTypes.shape({
id: PropTypes.string,
status: PropTypes.string
}),
}).isRequired,
asset: PropTypes.shape({
title: PropTypes.string,
url: PropTypes.string,
@@ -424,6 +424,17 @@ span {
top: 7px;
}
.username {
color: blue;
text-decoration: underline;
padding: 5px;
cursor: pointer;
&:hover {
background-color: rgba(255, 0, 0, .1);
}
}
.external {
font-size: .7em;
text-decoration: none;
@@ -4,6 +4,7 @@ import MOD_QUEUE_QUERY from './modQueueQuery.graphql';
import MOD_QUEUE_LOAD_MORE from './loadMore.graphql';
import MOD_USER_FLAGGED_QUERY from './modUserFlaggedQuery.graphql';
import METRICS from './metricsQuery.graphql';
import USER_DETAIL from './userDetail.graphql';
import GET_QUEUE_COUNTS from './getQueueCounts.graphql';
export const modQueueQuery = graphql(MOD_QUEUE_QUERY, {
@@ -95,6 +96,14 @@ export const modQueueResort = (id, fetchMore) => (sort) => {
});
};
export const getUserDetail = graphql(USER_DETAIL, {
options: ({id}) => {
return {
variables: {author_id: id}
};
}
});
export const getQueueCounts = graphql(GET_QUEUE_COUNTS, {
options: ({params: {id = null}}) => {
return {
@@ -0,0 +1,13 @@
query UserDetail ($author_id: ID!) {
user(id: $author_id) {
id
username
created_at
profiles {
id
provider
}
}
totalComments: commentCount(query: {author_id: $author_id})
rejectedComments: commentCount(query: {author_id: $author_id, statuses: [REJECTED]})
}
@@ -7,6 +7,7 @@ const initialState = Map({
user: Map({}),
commentId: null,
commentStatus: null,
userDetailId: null,
banDialog: false,
shortcutsNoteVisible: window.localStorage.getItem('coral:shortcutsNote') || 'show'
});
@@ -38,6 +39,10 @@ export default function moderation (state = initialState, action) {
case actions.HIDE_SHORTCUTS_NOTE:
return state
.set('shortcutsNoteVisible', 'hide');
case actions.VIEW_USER_DETAIL:
return state.set('userDetailId', action.userId);
case actions.HIDE_USER_DETAIL:
return state.set('userDetailId', null);
default :
return state;
}
+33
View File
@@ -0,0 +1,33 @@
.drawer {
max-width: 700px;
min-width: 400px;
padding: 20px;
position: fixed;
top: 0;
right: 0;
bottom: 0;
background-color: white;
transition: transform 500ms ease-in-out;
box-shadow: -3px 0px 4px 0px rgba(0,0,0,0.15);
z-index: 10000;
}
.closeButton {
position: absolute;
width: 40px;
height: 40px;
left: -40px;
background-color: white;
border-radius: 4px 0 0 4px;
box-sizing: border-box;
font-size: 32px;
top: 60px;
box-shadow: -1px 3px 4px 0px rgba(0,0,0,0.15);
text-align: center;
padding-top: 10px;
cursor: pointer;
&:hover {
color: #ccc;
}
}
+19
View File
@@ -0,0 +1,19 @@
import React, {PropTypes} from 'react';
import styles from './Drawer.css';
import onClickOutside from 'react-onclickoutside';
const Drawer = ({children, handleClickOutside}) => {
return (
<div className={styles.drawer}>
<div className={styles.closeButton} onClick={handleClickOutside}>×</div>
{children}
</div>
);
};
Drawer.propTypes = {
active: PropTypes.bool,
handleClickOutside: PropTypes.func.isRequired
};
export default onClickOutside(Drawer);
+1
View File
@@ -23,3 +23,4 @@ export {default as Select} from './components/Select';
export {default as Option} from './components/Option';
export {default as SnackBar} from './components/SnackBar';
export {default as TextArea} from './components/TextArea';
export {default as Drawer} from './components/Drawer';
+6 -2
View File
@@ -120,7 +120,7 @@ const getParentCountByAssetIDPersonalized = async (context, {assetId, excludeIgn
const ignoredUsers = freshUser.ignoresUsers;
query.author_id = {$nin: ignoredUsers};
}
return CommentModel.where(query).count();
};
@@ -191,7 +191,7 @@ const getCountByParentIDPersonalized = async (context, {id, excludeIgnored}) =>
* @return {Promise} resolves to the counts of the comments from the
* query
*/
const getCommentCountByQuery = (context, {ids, statuses, asset_id, parent_id}) => {
const getCommentCountByQuery = (context, {ids, statuses, asset_id, parent_id, author_id}) => {
let query = CommentModel.find();
if (ids) {
@@ -210,6 +210,10 @@ const getCommentCountByQuery = (context, {ids, statuses, asset_id, parent_id}) =
query = query.where({parent_id});
}
if (author_id) {
query = query.where({author_id});
}
return CommentModel
.find(query)
.count();
+9 -1
View File
@@ -15,7 +15,7 @@ const genUserByIDs = (context, ids) => UsersService
* @param {Object} context graph context
* @param {Object} query query terms to apply to the users query
*/
const getUsersByQuery = ({user}, {ids, limit, cursor, sort}) => {
const getUsersByQuery = ({user}, {ids, limit, cursor, statuses = null, sort}) => {
let users = UserModel.find();
@@ -27,6 +27,14 @@ const getUsersByQuery = ({user}, {ids, limit, cursor, sort}) => {
});
}
if (statuses != null) {
users = users.where({
status: {
$in: statuses
}
});
}
if (cursor) {
if (sort === 'REVERSE_CHRONOLOGICAL') {
users = users.where({
+100 -1
View File
@@ -1,12 +1,91 @@
const debug = require('debug')('talk:graph:mutators:comment');
const errors = require('../../errors');
const ActionModel = require('../../models/action');
const AssetsService = require('../../services/assets');
const ActionsService = require('../../services/actions');
const CommentsService = require('../../services/comments');
const KarmaService = require('../../services/karma');
const linkify = require('linkify-it')();
const Wordlist = require('../../services/wordlist');
/**
* adjustKarma will adjust the affected user's karma depending on the moderators
* action.
*/
const adjustKarma = (Comments, id, status) => async () => {
try {
// Use the dataloader to get the comment that was just moderated and
// get the flag user's id's so we can adjust their karma too.
let [
comment,
flagUserIDs
] = await Promise.all([
// Load the comment that was just made/updated by the setCommentStatus
// operation.
Comments.get.load(id),
// Find all the flag actions that were referenced by this comment
// at this point in time.
ActionModel.find({
item_id: id,
item_type: 'COMMENTS',
action_type: 'FLAG'
}).then((actions) => {
// This is to ensure that this is always an array.
if (!actions) {
return [];
}
return actions.map(({user_id}) => user_id);
})
]);
debug(`Comment[${id}] by User[${comment.author_id}] was Status[${status}]`);
switch (status) {
case 'REJECTED':
// Reduce the user's karma.
debug(`CommentUser[${comment.author_id}] had their karma reduced`);
// Decrease the flag user's karma, the moderator disagreed with this
// action.
debug(`FlaggingUser[${flagUserIDs.join(', ')}] had their karma increased`);
await Promise.all([
KarmaService.modifyUser(comment.author_id, -1, 'comment'),
KarmaService.modifyUser(flagUserIDs, 1, 'flag', true)
]);
break;
case 'ACCEPTED':
// Increase the user's karma.
debug(`CommentUser[${comment.author_id}] had their karma increased`);
// Increase the flag user's karma, the moderator agreed with this
// action.
debug(`FlaggingUser[${flagUserIDs.join(', ')}] had their karma reduced`);
await Promise.all([
KarmaService.modifyUser(comment.author_id, 1, 'comment'),
KarmaService.modifyUser(flagUserIDs, -1, 'flag', true)
]);
break;
}
return;
} catch (e) {
console.error(e);
}
};
/**
* Creates a new comment.
* @param {Object} user the user performing the request
@@ -86,6 +165,7 @@ const filterNewComment = (context, {body, asset_id}) => {
* @return {Promise} resolves to the comment's status
*/
const resolveNewCommentStatus = async (context, {asset_id, body}, wordlist = {}, settings = {}) => {
let {user} = context;
// Check to see if the body is too short, if it is, then complain about it!
if (body.length < 2) {
@@ -123,6 +203,22 @@ const resolveNewCommentStatus = async (context, {asset_id, body}, wordlist = {},
return 'REJECTED';
}
if (user && user.metadata) {
// If the user is not a reliable commenter (passed the unreliability
// threshold by having too many rejected comments) then we can change the
// status of the comment to `PREMOD`, therefore pushing the user's comments
// away from the public eye until a moderator can manage them. This of
// course can only be applied if the comment's current status is `NONE`,
// we don't want to interfere if the comment was rejected.
if (KarmaService.isReliable('comment', user.metadata.trust) === false) {
// Update the response from the comment creation to add the PREMOD so that
// that user's UI will reflect the fact that their comment is in pre-mod.
return 'PREMOD';
}
}
return moderation === 'PRE' ? 'PREMOD' : 'NONE';
};
@@ -179,7 +275,6 @@ const createPublicComment = async (context, commentInput) => {
* @param {String} id identifier of the comment (uuid)
* @param {String} status the new status of the comment
*/
const setStatus = async ({user, loaders: {Comments}}, {id, status}) => {
let comment = await CommentsService.pushStatus(id, status, user ? user.id : null);
@@ -196,6 +291,10 @@ const setStatus = async ({user, loaders: {Comments}}, {id, status}) => {
Comments.countByAssetID.clear(comment.asset_id);
// postSetCommentStatus will use the arguments from the mutation and
// adjust the affected user's karma in the next tick.
process.nextTick(adjustKarma(Comments, id, status));
return comment;
};
+25 -21
View File
@@ -19,34 +19,32 @@ const RootQuery = {
// This endpoint is used for loading moderation queues, so hide it in the
// event that we aren't an admin.
async comments(_, {query: {action_type, statuses, asset_id, parent_id, limit, cursor, sort, excludeIgnored}}, {user, loaders: {Comments, Actions}}) {
let query = {statuses, asset_id, parent_id, limit, cursor, sort, excludeIgnored};
async comments(_, {query}, {user, loaders: {Comments, Actions}}) {
let {action_type} = query;
if (user != null && user.hasRoles('ADMIN') && action_type) {
let ids = await Actions.getByTypes({action_type, item_type: 'COMMENTS'});
// Perform the query using the available resolver.
return Comments.getByQuery({ids, statuses, asset_id, parent_id, limit, cursor, sort, excludeIgnored});
query.ids = await Actions.getByTypes({action_type, item_type: 'COMMENTS'});
}
return Comments.getByQuery(query);
},
comment(_, {id}, {loaders: {Comments}}) {
return Comments.get.load(id);
},
async commentCount(_, {query: {action_type, statuses, asset_id, parent_id}}, {user, loaders: {Actions, Comments}}) {
async commentCount(_, {query}, {user, loaders: {Actions, Comments}}) {
if (user == null || !user.hasRoles('ADMIN')) {
return null;
}
if (action_type) {
let ids = await Actions.getByTypes({action_type, item_type: 'COMMENTS'});
const {action_type} = query;
// Perform the query using the available resolver.
return Comments.getCountByQuery({ids, statuses, asset_id, parent_id});
if (action_type) {
query.ids = await Actions.getByTypes({action_type, item_type: 'COMMENTS'});
}
return Comments.getCountByQuery({statuses, asset_id, parent_id});
return Comments.getCountByQuery(query);
},
assetMetrics(_, {from, to, sort, limit = 10}, {user, loaders: {Metrics: {Assets}}}) {
@@ -79,21 +77,27 @@ const RootQuery = {
return user;
},
// This endpoint is used for loading the user moderation queues (users whose username has been flagged),
// so hide it in the event that we aren't an admin.
async users(_, {query: {action_type, limit, cursor, sort}}, {user, loaders: {Users, Actions}}) {
// this returns an arbitrary user
user(_, {id}, {user, loaders: {Users}}) {
if (user == null || !user.hasRoles('ADMIN')) {
return null;
}
const query = {limit, cursor, sort};
return Users.getByID.load(id);
},
// This endpoint is used for loading the user moderation queues (users whose username has been flagged),
// so hide it in the event that we aren't an admin.
async users(_, {query}, {user, loaders: {Users, Actions}}) {
if (user == null || !user.hasRoles('ADMIN')) {
return null;
}
const {action_type} = query;
if (action_type) {
let ids = await Actions.getByTypes({action_type, item_type: 'USERS'});
// Perform the query using the available resolver.
return Users.getByQuery({ids, limit, cursor, sort}).find({status: 'PENDING'});
query.ids = await Actions.getByTypes({action_type, item_type: 'USERS'});
query.statuses = ['PENDING'];
}
return Users.getByQuery(query);
+25
View File
@@ -1,3 +1,5 @@
const KarmaService = require('../../services/karma');
const User = {
action_summaries({id}, _, {loaders: {Actions}}) {
return Actions.getSummariesByItemID.load(id);
@@ -10,6 +12,13 @@ const User = {
}
},
created_at({roles, created_at}, _, {user}) {
if (user && user.hasRoles('ADMIN')) {
return created_at;
}
return null;
},
comments({id}, _, {loaders: {Comments}, user}) {
// If the user is not an admin, only return comment list for the owner of
@@ -20,6 +29,15 @@ const User = {
return null;
},
profiles({profiles}, _, {user}) {
// if the user is not an admin, do not return the profiles
if (user && user.hasRoles('ADMIN')) {
return profiles;
}
return null;
},
ignoredUsers({id}, args, {user, loaders: {Users}}) {
// Only allow a logged in user that is either the current user or is a staff
@@ -43,6 +61,13 @@ const User = {
}
return null;
},
// Extract the reliability from the user metadata if they have permission.
reliable(user, _, {user: requestingUser}) {
if (requestingUser && requestingUser.hasRoles('ADMIN')) {
return KarmaService.model(user);
}
}
};
+10 -11
View File
@@ -1,6 +1,7 @@
const {SubscriptionManager} = require('graphql-subscriptions');
const {SubscriptionServer} = require('subscriptions-transport-ws');
const _ = require('lodash');
const debug = require('debug')('talk:graph:subscriptions');
const pubsub = require('./pubsub');
const schema = require('./schema');
@@ -9,24 +10,22 @@ const plugins = require('../services/plugins');
const {deserializeUser} = require('../services/subscriptions');
// Core setup functions
let setupFunctions = {
commentAdded: (options, args) => ({
commentAdded: {
filter: (comment) => comment.asset_id === args.asset_id
},
}),
};
/**
* Plugin support requires that we merge in existing setupFunctions with our new
* plugin based ones. This allows plugins to extend existing setupFunctions as well
* as provide new ones.
*/
setupFunctions = plugins.get('server', 'setupFunctions').reduce((acc, {setupFunctions}) => {
const setupFunctions = plugins.get('server', 'setupFunctions').reduce((acc, {plugin, setupFunctions}) => {
debug(`added plugin '${plugin.name}'`);
return _.merge(acc, setupFunctions);
}, setupFunctions);
}, {
commentAdded: (options, args) => ({
commentAdded: {
filter: (comment) => comment.asset_id === args.asset_id
},
}),
});
/**
* This creates a new subscription manager.
+43 -1
View File
@@ -5,6 +5,22 @@
# Date represented as an ISO8601 string.
scalar Date
################################################################################
## Reliability
################################################################################
# Reliability defines how a given user should be considered reliable for their
# comment or flag activity.
type Reliability {
# flagger will be `true` when the flagger is reliable, `false` if not, or
# `null` if the reliability cannot be determined.
flagger: Boolean
# commenter will be `true` when the commenter is reliable, `false` if not, or
# `null` if the reliability cannot be determined.
commenter: Boolean
}
################################################################################
## Users
@@ -20,6 +36,14 @@ enum USER_ROLES {
MODERATOR
}
type UserProfile {
# the id is an identifier for the user profile (email, facebook id, etc)
id: String!
# name of the provider attached to the authentication mode
provider: String!
}
# Any person who can author comments, create actions, and view comments on a
# stream.
type User {
@@ -30,6 +54,9 @@ type User {
# Username of a user.
username: String!
# creation date of user
created_at: String!
# Action summaries against the user.
action_summaries: [ActionSummary!]!
@@ -39,6 +66,9 @@ type User {
# the current roles of the user.
roles: [USER_ROLES!]
# the current profiles of the user.
profiles: [UserProfile]
# determines whether the user can edit their username
canEditName: Boolean
@@ -48,6 +78,11 @@ type User {
# returns all comments based on a query.
comments(query: CommentsQuery): [Comment!]
# reliable is the reference to a given user's Reliability. If the requesting
# user does not have permission to access the reliability, null will be
# returned.
reliable: Reliability
# returns user status
status: USER_STATUS
}
@@ -159,6 +194,10 @@ input CommentCountQuery {
# type.
action_type: ACTION_TYPE
# author_id allows the querying of comment counts based on the author of the
# comments.
author_id: ID
# Filter by a specific tag name.
tag: [String]
}
@@ -549,6 +588,9 @@ type RootQuery {
# Users returned based on a query.
users(query: UsersQuery): [User]
# a single User by id
user(id: ID!): User
# Asset metrics related to user actions are saturated into the assets
# returned. Parameters `from` and `to` are related to the action created_at field.
assetMetrics(from: Date!, to: Date!, sort: ASSET_METRICS_SORT!, limit: Int = 10): [Asset!]
@@ -741,7 +783,7 @@ type EditCommentResponse implements Response {
comment: Comment
# An array of errors relating to the mutation that occured.
errors: [UserError]
errors: [UserError]
}
# All mutations for the application are defined on this object.
+1 -1
View File
@@ -176,7 +176,7 @@
"react-linkify": "^0.1.3",
"react-mdl": "^1.7.2",
"react-mdl-selectfield": "^0.2.0",
"react-onclickoutside": "^5.7.1",
"react-onclickoutside": "^5.11.1",
"react-redux": "^4.4.5",
"react-router": "^3.0.0",
"react-tagsinput": "^3.14.0",
+155
View File
@@ -0,0 +1,155 @@
const debug = require('debug')('talk:trust');
const UserModel = require('../models/user');
/**
* This will create an object with the property name of the action type as the
* key and an object as it's value. This will contain a RELIABLE, and UNRELIABLE
* property with the number of karma points associated with their particular
* state.
*
* If only the RELIABLE variable is provided, then it will also be used as the
* UNRELIABLE variable.
*
* The form of the environment variable is:
*
* <name>:<RELIABLE>,<UNRELIABLE>;<name>:<RELIABLE>,<UNRELIABLE>;...
*
* The default used is:
*
* comment:1,1;flag:-1,-1
*/
const parseThresholds = (thresholds) => thresholds
.split(';')
.filter((threshold) => threshold && threshold.length > 0)
.reduce((acc, threshold) => {
const thresholds = threshold.split(':');
if (thresholds.length < 2) {
return acc;
}
let [name, values] = thresholds;
let [RELIABLE, UNRELIABLE] = values.split(',').map((value) => parseInt(value));
if (!(name in acc)) {
acc[name] = {};
}
if (isNaN(UNRELIABLE) && !isNaN(RELIABLE)) {
acc[name].RELIABLE = RELIABLE;
acc[name].UNRELIABLE = RELIABLE;
} else {
if (!isNaN(UNRELIABLE)) {
acc[name].UNRELIABLE = UNRELIABLE;
}
if (!isNaN(RELIABLE)) {
acc[name].RELIABLE = RELIABLE;
}
}
return acc;
}, {
comment: {
RELIABLE: -1,
UNRELIABLE: -1
},
flag: {
RELIABLE: -1,
UNRELIABLE: -1
}
});
const THRESHOLDS = parseThresholds(process.env.TRUST_THRESHOLDS || '');
debug('using thresholds: ', THRESHOLDS);
/**
* KarmaModel represents the checkable properties of a user and wrapps the
* KarmaService function `isReliable` to work flexibly with the graph.
*/
class KarmaModel {
constructor(model) {
this.model = model;
}
get flagger() {
return KarmaService.isReliable('flag', this.model);
}
get commenter() {
return KarmaService.isReliable('comment', this.model);
}
}
/**
* KarmaService provides interfaces for editing a user's karma.
*/
class KarmaService {
/**
* Model returns a KarmaModel based on the passed in user.
*/
static model(user) {
if (user === null || !user.metadata || !user.metadata.trust) {
return new KarmaModel({});
}
return new KarmaModel(user.metadata.trust);
}
/**
* Inspects the reliability of a property and returns it if known.
* @param {String} name - name of the property
* @param {Object} trust - object possibly containing the propertys
*/
static isReliable(name, trust) {
if (trust && trust[name]) {
if (trust[name].karma > THRESHOLDS[name].RELIABLE) {
return true;
} else if (trust[name].karma < THRESHOLDS[name].UNRELIABLE) {
return false;
}
} else if (THRESHOLDS[name].RELIABLE < 0) {
return true;
} else if (THRESHOLDS[name].UNRELIABLE > 0) {
return false;
}
return null;
}
/**
* modifyUserKarma updates the user to adjust their karma, for either the `type`
* of 'comment' or 'flag'. If `multi` is true, then it assumes that `id` is an
* array of id's.
*/
static async modifyUser(id, direction = 1, type = 'comment', multi = false) {
const key = `metadata.trust.${type}.karma`;
let update = {
$inc: {
[key]: direction
}
};
if (multi) {
// If it was in multi-mode but there was no user's to adjust, bail.
if (id.length <= 0) {
return;
}
return UserModel.update({
id: {
$in: id
}
}, update, {
multi: true
});
}
return UserModel.update({id}, update);
}
}
module.exports = KarmaService;
+119
View File
@@ -0,0 +1,119 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>GraphiQL</title>
<meta name="robots" content="noindex" />
<style>
html, body {
height: 100%;
margin: 0;
overflow: hidden;
width: 100%;
}
</style>
<link href="//cdn.jsdelivr.net/graphiql/0.9.1/graphiql.css" rel="stylesheet" />
<script src="//cdn.jsdelivr.net/fetch/0.9.0/fetch.min.js"></script>
<script src="//cdn.jsdelivr.net/react/15.0.0/react.min.js"></script>
<script src="//cdn.jsdelivr.net/react/15.0.0/react-dom.min.js"></script>
<script src="//cdn.jsdelivr.net/graphiql/0.9.1/graphiql.min.js"></script>
</head>
<body>
<script>
// Collect the URL parameters
var parameters = {};
window.location.search.substr(1).split('&').forEach(function (entry) {
var eq = entry.indexOf('=');
if (eq >= 0) {
parameters[decodeURIComponent(entry.slice(0, eq))] =
decodeURIComponent(entry.slice(eq + 1));
}
});
// Produce a Location query string from a parameter object.
function locationQuery(params, location) {
return (location ? location: '') + '?' + Object.keys(params).map(function (key) {
return encodeURIComponent(key) + '=' +
encodeURIComponent(params[key]);
}).join('&');
}
// Derive a fetch URL from the current URL, sans the GraphQL parameters.
var graphqlParamNames = {
query: true,
variables: true,
operationName: true
};
var otherParams = {};
for (var k in parameters) {
if (parameters.hasOwnProperty(k) && graphqlParamNames[k] !== true) {
otherParams[k] = parameters[k];
}
}
// We don't use safe-serialize for location, because it's not client input.
var fetchURL = locationQuery(otherParams, '<%= endpointURL %>');
// Defines a GraphQL fetcher using the fetch API.
function graphQLFetcher(graphQLParams) {
var headers = {
'Accept': 'application/json',
'Content-Type': 'application/json'
};
try {
let token = localStorage.getItem('token');
if (token) {
headers['Authorization'] = 'Bearer ' + token;
}
} catch (e) {
console.error(e);
}
return fetch(fetchURL, {
method: 'post',
headers: headers,
body: JSON.stringify(graphQLParams),
credentials: 'include',
}).then(function (response) {
return response.text();
}).then(function (responseBody) {
try {
return JSON.parse(responseBody);
} catch (error) {
return responseBody;
}
});
}
// When the query and variables string is edited, update the URL bar so
// that it can be easily shared.
function onEditQuery(newQuery) {
parameters.query = newQuery;
updateURL();
}
function onEditVariables(newVariables) {
parameters.variables = newVariables;
updateURL();
}
function onEditOperationName(newOperationName) {
parameters.operationName = newOperationName;
updateURL();
}
function updateURL() {
history.replaceState(null, null, locationQuery(parameters));
}
// Render <GraphiQL /> into the body.
ReactDOM.render(
React.createElement(GraphiQL, {
fetcher: graphQLFetcher,
onEditQuery: onEditQuery,
onEditVariables: onEditVariables,
onEditOperationName: onEditOperationName,
query: null,
response: null,
variables: null,
operationName: null,
}),
document.body
);
</script>
</body>
</html>
+1 -1
View File
@@ -6797,7 +6797,7 @@ react-mdl@^1.7.1, react-mdl@^1.7.2:
lodash.isequal "^4.4.0"
prop-types "^15.5.0"
react-onclickoutside@^5.7.1:
react-onclickoutside@^5.11.1:
version "5.11.1"
resolved "https://registry.yarnpkg.com/react-onclickoutside/-/react-onclickoutside-5.11.1.tgz#00314e52567cf55faba94cabbacd119619070623"
dependencies: