mirror of
https://github.com/wassname/talk.git
synced 2026-09-14 11:36:51 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
59c1656da3 | ||
|
|
f74d6071df | ||
|
|
b93f1e2a7d | ||
|
|
8196cb9c24 | ||
|
|
ba4f39aecd | ||
|
|
ca4c79681b | ||
|
|
093c13c103 | ||
|
|
72c1774cf3 | ||
|
|
a1bb0cd55f | ||
|
|
87ff9306de | ||
|
|
fd968b2070 | ||
|
|
b9f0633365 | ||
|
|
0ec582e411 | ||
|
|
4c79f45556 | ||
|
|
f7f0a9d99e | ||
|
|
a22d7af8bb | ||
|
|
4d89db931e | ||
|
|
2d0208046b | ||
|
|
e878d1ebfd | ||
|
|
f37c768662 | ||
|
|
3007ba68f8 | ||
|
|
1e80991614 | ||
|
|
ebcb3b4b02 | ||
|
|
f11a11cf67 | ||
|
|
c5d36a9f19 | ||
|
|
f80f253b98 | ||
|
|
71d2baa010 | ||
|
|
55973859ef | ||
|
|
6548a76bfb | ||
|
|
009b381744 | ||
|
|
3e00a4e842 | ||
|
|
57c1e58bc3 | ||
|
|
0726603a4e | ||
|
|
9d59c95cdf | ||
|
|
771f99cee8 | ||
|
|
00a74f05cb | ||
|
|
8680d6cab8 | ||
|
|
184b6c00a6 | ||
|
|
870fcac3b1 | ||
|
|
d80ed467f1 | ||
|
|
8435358879 | ||
|
|
5092b849b3 | ||
|
|
daa6ce60ab | ||
|
|
b7ce669cfc | ||
|
|
e75663f5b9 | ||
|
|
4e60689fa4 | ||
|
|
e7c0c5b638 | ||
|
|
4ff683b3af | ||
|
|
4f1f85d127 | ||
|
|
155be523ff | ||
|
|
3a47dd4c4b | ||
|
|
2ec5b243fb | ||
|
|
0769a836d1 | ||
|
|
68584cf2cf | ||
|
|
8a8700ebb2 | ||
|
|
542a261e5e | ||
|
|
d539e23c00 | ||
|
|
a4dabd0308 | ||
|
|
c35043f799 | ||
|
|
a6fa9a22c3 | ||
|
|
a2551a499f | ||
|
|
b5d86a3380 | ||
|
|
9eaed90318 | ||
|
|
2b90f2f588 | ||
|
|
8114608626 | ||
|
|
228fdeac4d | ||
|
|
701994f8f7 | ||
|
|
5d5ad2585d | ||
|
|
05efbc4ded | ||
|
|
323749915a | ||
|
|
84f1d3ece2 | ||
|
|
b9c816285d |
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"sourceMaps": true,
|
||||
"presets": [
|
||||
"es2015"
|
||||
],
|
||||
"plugins": [
|
||||
"add-module-exports",
|
||||
"transform-class-properties",
|
||||
"transform-decorators-legacy",
|
||||
"transform-object-assign",
|
||||
"transform-object-rest-spread",
|
||||
"transform-react-jsx"
|
||||
]
|
||||
}
|
||||
@@ -12,6 +12,8 @@ dump.rdb
|
||||
*.cfg
|
||||
.idea/
|
||||
coverage/
|
||||
.tags
|
||||
.tags1
|
||||
|
||||
# remove plugin folders
|
||||
plugins
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Talk [](https://circleci.com/gh/coralproject/talk)
|
||||
|
||||
A commenting platform from [The Coral Project](https://coralproject.net). Talk enters a closed beta in March 2017, but you can download the code for our alpha here. [Read more about Talk here.](https://coralproject.net/products/talk.html)
|
||||
Talk is currently in Beta! [Read more about Talk here.](https://coralproject.net/products/talk.html)
|
||||
|
||||
Third party licenses are available via the `/client/3rdpartylicenses.txt`
|
||||
endpoint when the server is running with built assets.
|
||||
|
||||
+9
-1
@@ -1,6 +1,14 @@
|
||||
{
|
||||
"extends": "../.babelrc",
|
||||
"presets": [
|
||||
"es2015"
|
||||
],
|
||||
"plugins": [
|
||||
"add-module-exports",
|
||||
"transform-class-properties",
|
||||
"transform-decorators-legacy",
|
||||
"transform-object-assign",
|
||||
"transform-object-rest-spread",
|
||||
"transform-async-to-generator",
|
||||
"transform-react-jsx"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -5,10 +5,13 @@ import Stories from 'containers/Stories/Stories';
|
||||
import Configure from 'containers/Configure/Configure';
|
||||
import LayoutContainer from 'containers/LayoutContainer';
|
||||
import InstallContainer from 'containers/Install/InstallContainer';
|
||||
|
||||
import CommunityLayout from 'containers/Community/CommunityLayout';
|
||||
import CommunityContainer from 'containers/Community/CommunityContainer';
|
||||
|
||||
import ModerationLayout from 'containers/ModerationQueue/ModerationLayout';
|
||||
import ModerationContainer from 'containers/ModerationQueue/ModerationContainer';
|
||||
|
||||
import Dashboard from 'containers/Dashboard/Dashboard';
|
||||
|
||||
const routes = (
|
||||
@@ -21,6 +24,18 @@ const routes = (
|
||||
<Route path='stories' component={Stories} />
|
||||
<Route path='dashboard' component={Dashboard} />
|
||||
|
||||
{/* Community Routes */}
|
||||
|
||||
<Route path='community' component={CommunityLayout}>
|
||||
<Route path='flagged' components={CommunityContainer}>
|
||||
<Route path=':id' components={CommunityContainer} />
|
||||
</Route>
|
||||
<Route path='people' components={CommunityContainer}>
|
||||
<Route path=':id' components={CommunityContainer} />
|
||||
</Route>
|
||||
<IndexRedirect to='flagged' />
|
||||
</Route>
|
||||
|
||||
{/* Moderation Routes */}
|
||||
|
||||
<Route path='moderate' component={ModerationLayout}>
|
||||
|
||||
@@ -7,28 +7,33 @@ import {
|
||||
SORT_UPDATE,
|
||||
COMMENTERS_NEW_PAGE,
|
||||
SET_ROLE,
|
||||
SET_COMMENTER_STATUS
|
||||
SET_COMMENTER_STATUS,
|
||||
SHOW_BANUSER_DIALOG,
|
||||
HIDE_BANUSER_DIALOG,
|
||||
SHOW_SUSPENDUSER_DIALOG,
|
||||
HIDE_SUSPENDUSER_DIALOG
|
||||
} from '../constants/community';
|
||||
|
||||
import coralApi from '../../../coral-framework/helpers/response';
|
||||
|
||||
export const fetchCommenters = (query = {}) => dispatch => {
|
||||
dispatch(requestFetchCommenters());
|
||||
export const fetchAccounts = (query = {}) => dispatch => {
|
||||
|
||||
dispatch(requestFetchAccounts());
|
||||
coralApi(`/users?${qs.stringify(query)}`)
|
||||
.then(({result, page, count, limit, totalPages}) =>
|
||||
.then(({result, page, count, limit, totalPages}) =>{
|
||||
dispatch({
|
||||
type: FETCH_COMMENTERS_SUCCESS,
|
||||
commenters: result,
|
||||
accounts: result,
|
||||
page,
|
||||
count,
|
||||
limit,
|
||||
totalPages
|
||||
})
|
||||
)
|
||||
});
|
||||
})
|
||||
.catch(error => dispatch({type: FETCH_COMMENTERS_FAILURE, error}));
|
||||
};
|
||||
|
||||
const requestFetchCommenters = () => ({
|
||||
const requestFetchAccounts = () => ({
|
||||
type: FETCH_COMMENTERS_REQUEST
|
||||
});
|
||||
|
||||
@@ -55,3 +60,11 @@ export const setCommenterStatus = (id, status) => (dispatch) => {
|
||||
return dispatch({type: SET_COMMENTER_STATUS, id, status});
|
||||
});
|
||||
};
|
||||
|
||||
// Ban User Dialog
|
||||
export const showBanUserDialog = (user) => ({type: SHOW_BANUSER_DIALOG, user});
|
||||
export const hideBanUserDialog = () => ({type: HIDE_BANUSER_DIALOG});
|
||||
|
||||
// Suspend User Dialog
|
||||
export const showSuspendUserDialog = (user) => ({type: SHOW_SUSPENDUSER_DIALOG, user});
|
||||
export const hideSuspendUserDialog = () => ({type: HIDE_SUSPENDUSER_DIALOG});
|
||||
|
||||
@@ -0,0 +1,622 @@
|
||||
$minHeight: 200px;
|
||||
$fullscreenZIndex: 10;
|
||||
|
||||
.wrapper {
|
||||
/* Fix blockquote styling of MDL: https://github.com/google/material-design-lite/issues/2037 */
|
||||
blockquote {
|
||||
> p:first-child {
|
||||
padding-top: 16px;
|
||||
}
|
||||
> p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
&::after {
|
||||
margin-left: -0.5em;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.iconBold {
|
||||
composes: material-icons from global;
|
||||
&::before {
|
||||
content: 'format_bold';
|
||||
}
|
||||
}
|
||||
|
||||
.iconItalic {
|
||||
composes: material-icons from global;
|
||||
&::before {
|
||||
content: 'format_italic';
|
||||
}
|
||||
}
|
||||
|
||||
.iconTitle {
|
||||
composes: material-icons from global;
|
||||
&::before {
|
||||
content: 'title';
|
||||
}
|
||||
}
|
||||
|
||||
.iconQuote {
|
||||
composes: material-icons from global;
|
||||
&::before {
|
||||
content: 'format_quote';
|
||||
}
|
||||
}
|
||||
|
||||
.iconUnorderedList {
|
||||
composes: material-icons from global;
|
||||
&::before {
|
||||
content: 'format_list_bulleted';
|
||||
}
|
||||
}
|
||||
|
||||
.iconOrderedList {
|
||||
composes: material-icons from global;
|
||||
&::before {
|
||||
content: 'format_list_numbered';
|
||||
}
|
||||
}
|
||||
|
||||
.iconLink {
|
||||
composes: material-icons from global;
|
||||
&::before {
|
||||
content: 'link';
|
||||
}
|
||||
}
|
||||
|
||||
.iconImage {
|
||||
composes: material-icons from global;
|
||||
&::before {
|
||||
content: 'insert_photo';
|
||||
}
|
||||
}
|
||||
|
||||
.iconPreview {
|
||||
composes: material-icons from global;
|
||||
&::before {
|
||||
content: 'remove_red_eye';
|
||||
}
|
||||
}
|
||||
|
||||
.iconSideBySide {
|
||||
composes: material-icons from global;
|
||||
&::before {
|
||||
content: 'chrome_reader_mode';
|
||||
}
|
||||
}
|
||||
|
||||
.iconFullscreen {
|
||||
composes: material-icons from global;
|
||||
&::before {
|
||||
content: 'fullscreen';
|
||||
}
|
||||
}
|
||||
|
||||
.iconGuide {
|
||||
composes: material-icons from global;
|
||||
&::before {
|
||||
content: 'help_outline';
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* These are modified styles taken from https://github.com/NextStepWebs/simplemde-markdown-editor and
|
||||
* put through http://sebastianpontow.de/css2compass/.
|
||||
*/
|
||||
|
||||
/*
|
||||
* simplemde v1.11.2
|
||||
* Copyright Next Step Webs, Inc.
|
||||
* @link https://github.com/NextStepWebs/simplemde-markdown-editor
|
||||
* @license MIT
|
||||
*/
|
||||
:global {
|
||||
.CodeMirror {
|
||||
font-family: monospace;
|
||||
color: black;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: white;
|
||||
height: auto;
|
||||
min-height: $minHeight;
|
||||
border: 1px solid #ddd;
|
||||
border-bottom-left-radius: 4px;
|
||||
border-bottom-right-radius: 4px;
|
||||
padding: 10px;
|
||||
font: inherit;
|
||||
z-index: 1;
|
||||
pre {
|
||||
padding: 0 4px;
|
||||
border-radius: 0;
|
||||
border-width: 0;
|
||||
background: transparent;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
margin: 0;
|
||||
white-space: pre;
|
||||
word-wrap: normal;
|
||||
line-height: inherit;
|
||||
color: inherit;
|
||||
z-index: 2;
|
||||
position: relative;
|
||||
overflow: visible;
|
||||
font-variant-ligatures: none;
|
||||
}
|
||||
span {
|
||||
/*TODO: vertical-align: text-bottom;*/
|
||||
}
|
||||
.CodeMirror-code {
|
||||
.cm-tag {
|
||||
color: #63a35c;
|
||||
}
|
||||
.cm-attribute {
|
||||
color: #795da3;
|
||||
}
|
||||
.cm-string {
|
||||
color: #183691;
|
||||
}
|
||||
.cm-header-1 {
|
||||
font-size: 200%;
|
||||
line-height: 200%;
|
||||
}
|
||||
.cm-header-2 {
|
||||
font-size: 160%;
|
||||
line-height: 160%;
|
||||
}
|
||||
.cm-header-3 {
|
||||
font-size: 125%;
|
||||
line-height: 125%;
|
||||
}
|
||||
.cm-header-4 {
|
||||
font-size: 110%;
|
||||
line-height: 110%;
|
||||
}
|
||||
.cm-comment {
|
||||
background: rgba(0, 0, 0, .05);
|
||||
border-radius: 2px;
|
||||
}
|
||||
.cm-link {
|
||||
color: #7f8c8d;
|
||||
}
|
||||
.cm-url {
|
||||
color: #aab2b3;
|
||||
}
|
||||
.cm-strikethrough {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
.cm-tab {
|
||||
display: inline-block;
|
||||
text-decoration: inherit;
|
||||
}
|
||||
.CodeMirror-ruler {
|
||||
border-left: 1px solid #ccc;
|
||||
position: absolute;
|
||||
}
|
||||
.cm-header {
|
||||
font-weight: bold;
|
||||
}
|
||||
.cm-strong {
|
||||
font-weight: bold;
|
||||
}
|
||||
.cm-em {
|
||||
font-style: italic;
|
||||
}
|
||||
.cm-link {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.cm-strikethrough {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
.cm-invalidchar {
|
||||
color: #f00;
|
||||
}
|
||||
}
|
||||
.CodeMirror-selected {
|
||||
background: #d9d9d9;
|
||||
}
|
||||
.CodeMirror-placeholder {
|
||||
opacity: .5;
|
||||
}
|
||||
div.CodeMirror-secondarycursor {
|
||||
border-left: 1px solid silver;
|
||||
}
|
||||
.cm-spell-error:not(.cm-url):not(.cm-comment):not(.cm-tag):not(.cm-word) {
|
||||
background: rgba(255, 0, 0, .15);
|
||||
}
|
||||
}
|
||||
.CodeMirror-lines {
|
||||
padding: 4px 0;
|
||||
cursor: text;
|
||||
min-height: 1px;
|
||||
}
|
||||
.CodeMirror-scrollbar-filler {
|
||||
background-color: white;
|
||||
position: absolute;
|
||||
z-index: 6;
|
||||
display: none;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
.CodeMirror-gutter-filler {
|
||||
background-color: white;
|
||||
position: absolute;
|
||||
z-index: 6;
|
||||
display: none;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
.CodeMirror-gutters {
|
||||
border-right: 1px solid #ddd;
|
||||
background-color: #f7f7f7;
|
||||
white-space: nowrap;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
min-height: 100%;
|
||||
z-index: 3;
|
||||
box-sizing: content-box;
|
||||
}
|
||||
.CodeMirror-guttermarker {
|
||||
color: black;
|
||||
}
|
||||
.CodeMirror-guttermarker-subtle {
|
||||
color: #999;
|
||||
}
|
||||
.CodeMirror-cursors {
|
||||
visibility: hidden;
|
||||
position: relative;
|
||||
z-index: 3;
|
||||
}
|
||||
.CodeMirror-cursor {
|
||||
border-left: 1px solid black;
|
||||
border-right: none;
|
||||
width: 0;
|
||||
position: absolute;
|
||||
}
|
||||
@keyframes blink {
|
||||
0% {
|
||||
}
|
||||
50% {
|
||||
background-color: transparent;
|
||||
}
|
||||
100% {
|
||||
}
|
||||
}
|
||||
.CodeMirror-scroll {
|
||||
overflow: scroll !important;
|
||||
margin-bottom: -30px;
|
||||
margin-right: -30px;
|
||||
padding-bottom: 30px;
|
||||
height: 100%;
|
||||
outline: none;
|
||||
position: relative;
|
||||
min-height: $minHeight;
|
||||
box-sizing: content-box;
|
||||
}
|
||||
.CodeMirror-sizer {
|
||||
position: relative;
|
||||
border-right: 30px solid transparent;
|
||||
box-sizing: content-box;
|
||||
}
|
||||
.CodeMirror-vscrollbar {
|
||||
position: absolute;
|
||||
z-index: 6;
|
||||
display: none;
|
||||
right: 0;
|
||||
top: 0;
|
||||
overflow-x: hidden;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
.CodeMirror-hscrollbar {
|
||||
position: absolute;
|
||||
z-index: 6;
|
||||
display: none;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
overflow-y: hidden;
|
||||
overflow-x: scroll;
|
||||
}
|
||||
.CodeMirror-gutter {
|
||||
white-space: normal;
|
||||
height: 100%;
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
margin-bottom: -30px;
|
||||
*zoom: 1;
|
||||
*display: inline;
|
||||
box-sizing: content-box;
|
||||
}
|
||||
.CodeMirror-gutter-wrapper {
|
||||
position: absolute;
|
||||
z-index: 4;
|
||||
background: none !important;
|
||||
border: none !important;
|
||||
user-select: none;
|
||||
}
|
||||
.CodeMirror-gutter-background {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
z-index: 4;
|
||||
}
|
||||
.CodeMirror-gutter-elt {
|
||||
position: absolute;
|
||||
cursor: default;
|
||||
z-index: 4;
|
||||
}
|
||||
.CodeMirror-code {
|
||||
outline: none;
|
||||
}
|
||||
.CodeMirror-measure {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 0;
|
||||
overflow: hidden;
|
||||
visibility: hidden;
|
||||
pre {
|
||||
position: static;
|
||||
}
|
||||
}
|
||||
.CodeMirror-focused {
|
||||
.CodeMirror-selected {
|
||||
background: #d7d4f0;
|
||||
}
|
||||
div.CodeMirror-cursors {
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
.CodeMirror-selected {
|
||||
background: #d9d9d9;
|
||||
}
|
||||
.CodeMirror-line::selection {
|
||||
background: #d7d4f0;
|
||||
}
|
||||
.CodeMirror-line {
|
||||
> span::selection {
|
||||
background: #d7d4f0;
|
||||
}
|
||||
> span {
|
||||
> span::selection {
|
||||
background: #d7d4f0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@media print {
|
||||
.CodeMirror div.CodeMirror-cursors {
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
.CodeMirror-fullscreen {
|
||||
background: #fff;
|
||||
position: fixed !important;
|
||||
top: 50px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
height: auto;
|
||||
z-index: $fullscreenZIndex;
|
||||
}
|
||||
.CodeMirror-sided {
|
||||
width: 50% !important;
|
||||
}
|
||||
.editor-toolbar {
|
||||
position: relative;
|
||||
opacity: .6;
|
||||
user-select: none;
|
||||
padding: 0 10px;
|
||||
border-top: 1px solid #bbb;
|
||||
border-left: 1px solid #bbb;
|
||||
border-right: 1px solid #bbb;
|
||||
border-top-left-radius: 4px;
|
||||
border-top-right-radius: 4px;
|
||||
&:after {
|
||||
display: block;
|
||||
content: ' ';
|
||||
height: 1px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
&:before {
|
||||
display: block;
|
||||
content: ' ';
|
||||
height: 1px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
&:hover {
|
||||
opacity: .8;
|
||||
}
|
||||
&.fullscreen {
|
||||
width: 100%;
|
||||
height: 50px;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
white-space: nowrap;
|
||||
padding-top: 10px;
|
||||
padding-bottom: 10px;
|
||||
box-sizing: border-box;
|
||||
background: #fff;
|
||||
border: 0;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
opacity: 1;
|
||||
z-index: $fullscreenZIndex;
|
||||
}
|
||||
&.fullscreen::before {
|
||||
width: 20px;
|
||||
height: 50px;
|
||||
background: linear-gradient(to right, rgba(255, 255, 255, 1) 0, rgba(255, 255, 255, 0) 100%);
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
&.fullscreen::after {
|
||||
width: 20px;
|
||||
height: 50px;
|
||||
background: linear-gradient(to right, rgba(255, 255, 255, 0) 0, rgba(255, 255, 255, 1) 100%);
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
a {
|
||||
display: inline-block;
|
||||
text-align: center;
|
||||
text-decoration: none!important;
|
||||
color: #2c3e50!important;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
margin: 0;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
outline: 0;
|
||||
margin-right: 2px;
|
||||
&.active {
|
||||
background: #fcfcfc;
|
||||
border-color: #95a5a6;
|
||||
}
|
||||
&:hover {
|
||||
background: #fcfcfc;
|
||||
border-color: #95a5a6;
|
||||
}
|
||||
&:active {
|
||||
background: #eee;
|
||||
}
|
||||
&:before {
|
||||
line-height: 30px;
|
||||
}
|
||||
}
|
||||
i.separator {
|
||||
display: inline-block;
|
||||
width: 0;
|
||||
border-left: 1px solid #d9d9d9;
|
||||
border-right: 1px solid #fff;
|
||||
color: transparent;
|
||||
text-indent: -10px;
|
||||
margin: 0 6px;
|
||||
}
|
||||
&.disabled-for-preview a:not(.no-disable) {
|
||||
pointer-events: none;
|
||||
background: #fff;
|
||||
border-color: transparent;
|
||||
text-shadow: inherit;
|
||||
}
|
||||
}
|
||||
@media only screen and(max-width: 700px) {
|
||||
.editor-toolbar a.no-mobile {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
.editor-statusbar {
|
||||
padding: 8px 10px;
|
||||
font-size: 12px;
|
||||
color: #959694;
|
||||
text-align: right;
|
||||
span {
|
||||
display: inline-block;
|
||||
min-width: 4em;
|
||||
margin-left: 1em;
|
||||
}
|
||||
.lines:before {
|
||||
content: 'lines: ';
|
||||
}
|
||||
.words:before {
|
||||
content: 'words: ';
|
||||
}
|
||||
.characters:before {
|
||||
content: 'characters: ';
|
||||
}
|
||||
}
|
||||
.editor-preview {
|
||||
padding: 10px;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
top: 0;
|
||||
left: 0;
|
||||
background: #fafafa;
|
||||
z-index: 7;
|
||||
overflow: auto;
|
||||
display: none;
|
||||
box-sizing: border-box;
|
||||
> p {
|
||||
margin-top: 0;
|
||||
}
|
||||
pre {
|
||||
background: #eee;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
table {
|
||||
td {
|
||||
border: 1px solid #ddd;
|
||||
padding: 5px;
|
||||
}
|
||||
th {
|
||||
border: 1px solid #ddd;
|
||||
padding: 5px;
|
||||
}
|
||||
}
|
||||
}
|
||||
.editor-preview-side {
|
||||
padding: 10px;
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
width: 50%;
|
||||
top: 50px;
|
||||
right: 0;
|
||||
background: #fafafa;
|
||||
z-index: $fullscreenZIndex;
|
||||
overflow: auto;
|
||||
display: none;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid #ddd;
|
||||
> p {
|
||||
margin-top: 0;
|
||||
}
|
||||
pre {
|
||||
background: #eee;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
table {
|
||||
td {
|
||||
border: 1px solid #ddd;
|
||||
padding: 5px;
|
||||
}
|
||||
th {
|
||||
border: 1px solid #ddd;
|
||||
padding: 5px;
|
||||
}
|
||||
}
|
||||
}
|
||||
.editor-preview-active-side {
|
||||
display: block;
|
||||
}
|
||||
.editor-preview-active {
|
||||
display: block;
|
||||
}
|
||||
.CodeMirror-overwrite .CodeMirror-cursor {
|
||||
}
|
||||
.CodeMirror-wrap pre {
|
||||
word-wrap: break-word;
|
||||
white-space: pre-wrap;
|
||||
word-break: normal;
|
||||
}
|
||||
.cm-tab-wrap-hack:after {
|
||||
content: '';
|
||||
}
|
||||
span.CodeMirror-selectedtext {
|
||||
background: none;
|
||||
}
|
||||
.editor-wrapper input.title {
|
||||
&:focus {
|
||||
opacity: .8;
|
||||
}
|
||||
&:hover {
|
||||
opacity: .8;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import React, {Component, PropTypes} from 'react';
|
||||
import SimpleMDE from 'simplemde';
|
||||
import cn from 'classnames';
|
||||
import noop from 'lodash/noop';
|
||||
import styles from './MarkdownEditor.css';
|
||||
|
||||
const config = {
|
||||
status: false,
|
||||
|
||||
// Do not download fontAwesome icons as we replace them with
|
||||
// material icons.
|
||||
autoDownloadFontAwesome: false,
|
||||
|
||||
// Disable built-in spell checker as it is very rudimentary.
|
||||
spellChecker: false,
|
||||
|
||||
toolbar: [
|
||||
{
|
||||
name: 'bold',
|
||||
action: SimpleMDE.toggleBold,
|
||||
className: styles.iconBold,
|
||||
title: 'Bold',
|
||||
},
|
||||
{
|
||||
name: 'italic',
|
||||
action: SimpleMDE.toggleItalic,
|
||||
className: styles.iconItalic,
|
||||
title: 'Italic',
|
||||
},
|
||||
{
|
||||
name: 'title',
|
||||
action: SimpleMDE.toggleHeadingSmaller,
|
||||
className: styles.iconTitle,
|
||||
title: 'Title, Subtitle, Heading',
|
||||
},
|
||||
'|',
|
||||
{
|
||||
name: 'quote',
|
||||
action: SimpleMDE.toggleBlockquote,
|
||||
className: styles.iconQuote,
|
||||
title: 'Quote',
|
||||
},
|
||||
{
|
||||
name: 'unordered-list',
|
||||
action: SimpleMDE.toggleUnorderedList,
|
||||
className: styles.iconUnorderedList,
|
||||
title: 'Generic List',
|
||||
},
|
||||
{
|
||||
name: 'ordered-list',
|
||||
action: SimpleMDE.toggleOrderedList,
|
||||
className: styles.iconOrderedList,
|
||||
title: 'Numbered List',
|
||||
},
|
||||
'|',
|
||||
{
|
||||
name: 'link',
|
||||
action: SimpleMDE.drawLink,
|
||||
className: styles.iconLink,
|
||||
title: 'Create Link',
|
||||
},
|
||||
{
|
||||
name: 'image',
|
||||
action: SimpleMDE.drawImage,
|
||||
className: styles.iconImage,
|
||||
title: 'Insert Image',
|
||||
},
|
||||
'|',
|
||||
{
|
||||
name: 'preview',
|
||||
action: SimpleMDE.togglePreview,
|
||||
className: cn(styles.iconPreview, 'no-disable'),
|
||||
title: 'Toggle Preview',
|
||||
},
|
||||
{
|
||||
name: 'side-by-side',
|
||||
action: SimpleMDE.toggleSideBySide,
|
||||
className: cn(styles.iconSideBySide, 'no-disable'),
|
||||
title: 'Toggle Side by Side',
|
||||
},
|
||||
{
|
||||
name: 'fullscreen',
|
||||
action: SimpleMDE.toggleFullScreen,
|
||||
className: cn(styles.iconFullscreen, 'no-disable'),
|
||||
title: 'Toggle Fullscreen',
|
||||
},
|
||||
'|',
|
||||
{
|
||||
name: 'guide',
|
||||
action: 'https://simplemde.com/markdown-guide',
|
||||
className: styles.iconGuide,
|
||||
title: 'Markdown Guide',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export default class MarkdownEditor extends Component {
|
||||
|
||||
textarea = null
|
||||
editor = null
|
||||
|
||||
onRef = (ref) => this.textarea = ref
|
||||
|
||||
componentDidMount() {
|
||||
this.editor = new SimpleMDE({
|
||||
...config,
|
||||
element: this.textarea,
|
||||
});
|
||||
this.editor.codemirror.on('change', this.onChange);
|
||||
}
|
||||
|
||||
componentWillReceiveProps(nextProps) {
|
||||
if (this.props.value !== nextProps.value && nextProps.value !== this.editor.value()) {
|
||||
this.editor.value(nextProps.value);
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
|
||||
// Workaround empty render issue.
|
||||
// https://github.com/NextStepWebs/simplemde-markdown-editor/issues/313
|
||||
this.editor.codemirror.refresh();
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.editor.toTextArea();
|
||||
}
|
||||
|
||||
onChange = () => {
|
||||
if (this.props.onChange) {
|
||||
this.props.onChange(this.editor.value());
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
<textarea ref={this.onRef} {...this.props} onChange={noop} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
MarkdownEditor.propTypes = {
|
||||
onChange: PropTypes.func,
|
||||
value: PropTypes.string,
|
||||
};
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
import React from 'react';
|
||||
import {Button, Icon} from 'react-mdl';
|
||||
import styles from './Modal.css';
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
import I18n from 'coral-framework/modules/i18n/i18n';
|
||||
import translations from '../translations.json';
|
||||
import React, {Component, PropTypes} from 'react';
|
||||
import Modal from 'components/Modal';
|
||||
import styles from './SuspendUserModal.css';
|
||||
import {Button} from 'coral-ui';
|
||||
|
||||
const stages = [
|
||||
{
|
||||
title: 'suspenduser.title_0',
|
||||
description: 'suspenduser.description_0',
|
||||
options: {
|
||||
'j': 'suspenduser.no_cancel',
|
||||
'k': 'suspenduser.yes_suspend'
|
||||
}
|
||||
},
|
||||
{
|
||||
title: 'suspenduser.title_1',
|
||||
description: 'suspenduser.description_1',
|
||||
options: {
|
||||
'j': 'bandialog.cancel',
|
||||
'k': 'suspenduser.send'
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
class SuspendUserModal extends Component {
|
||||
|
||||
state = {email: '', stage: 0}
|
||||
|
||||
static propTypes = {
|
||||
stage: PropTypes.number,
|
||||
actionType: PropTypes.string,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
suspendUser: PropTypes.func.isRequired
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const about = lang.t('suspenduser.username');
|
||||
this.setState({email: lang.t('suspenduser.email', about)});
|
||||
}
|
||||
|
||||
/*
|
||||
* When an admin clicks to suspend a user a dialog is shown, this function
|
||||
* handles the possible actions for that dialog.
|
||||
*/
|
||||
onActionClick = (stage, menuOption) => () => {
|
||||
const {suspendUser, action} = this.props;
|
||||
const {stage, email} = this.state;
|
||||
const cancel = this.props.onClose;
|
||||
const next = () => this.setState({stage: stage + 1});
|
||||
const suspend = () => suspendUser(action.item_id, lang.t('suspenduser.email_subject'), email)
|
||||
.then(this.props.onClose);
|
||||
const suspendModalActions = [
|
||||
[ cancel, next ],
|
||||
[ cancel, suspend ]
|
||||
];
|
||||
return suspendModalActions[stage][menuOption]();
|
||||
}
|
||||
|
||||
onEmailChange = (e) => this.setState({email: e.target.value})
|
||||
|
||||
render () {
|
||||
const {action, onClose} = this.props;
|
||||
|
||||
if (!action) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const {stage} = this.state;
|
||||
const actionType = action.actionType;
|
||||
const about = actionType === 'flag_bio' ? lang.t('suspenduser.bio') : lang.t('suspenduser.username');
|
||||
return <Modal open={true} onClose={onClose}>
|
||||
<div className={styles.title}>{lang.t(stages[stage].title, about)}</div>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.description}>
|
||||
{lang.t(stages[stage].description, about)}
|
||||
</div>
|
||||
{
|
||||
stage === 1 &&
|
||||
<div className={styles.writeContainer}>
|
||||
<div className={styles.emailMessage}>{lang.t('suspenduser.write_message')}</div>
|
||||
<div className={styles.emailContainer}>
|
||||
<textarea
|
||||
rows={5}
|
||||
className={styles.emailInput}
|
||||
value={this.state.email}
|
||||
onChange={this.onEmailChange}/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<div className={styles.modalButtons}>
|
||||
{Object.keys(stages[stage].options).map((key, i) => (
|
||||
<Button key={i} onClick={this.onActionClick(stage, i)}>
|
||||
{lang.t(stages[stage].options[key], about)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>;
|
||||
}
|
||||
}
|
||||
|
||||
export default SuspendUserModal;
|
||||
|
||||
const lang = new I18n(translations);
|
||||
@@ -1,50 +0,0 @@
|
||||
import React from 'react';
|
||||
import styles from './ModerationList.css';
|
||||
|
||||
import I18n from 'coral-framework/modules/i18n/i18n';
|
||||
import translations from '../translations.json';
|
||||
|
||||
import {Icon} from 'react-mdl';
|
||||
import ActionButton from './ActionButton';
|
||||
|
||||
// Render a single comment for the list
|
||||
const User = props => {
|
||||
const {action, user} = props;
|
||||
let userStatus = user.status;
|
||||
|
||||
// Do not display unless the user status is 'pending' or 'banned'.
|
||||
// This means that they have already been reviewed and approved.
|
||||
return (userStatus === 'PENDING' || userStatus === 'BANNED') &&
|
||||
<li tabIndex={props.index} className={`mdl-card mdl-shadow--2dp ${styles.listItem} ${props.isActive && !props.hideActive ? styles.activeItem : ''}`}>
|
||||
<div className={styles.itemHeader}>
|
||||
<div className={styles.author}>
|
||||
<span>{user.username}</span>
|
||||
</div>
|
||||
<div className={styles.sideActions}>
|
||||
<div className={`actions ${styles.actions}`}>
|
||||
{props.modActions.map(
|
||||
(action, i) =>
|
||||
<ActionButton
|
||||
type={action.toUpperCase()}
|
||||
key={i}
|
||||
user={user}
|
||||
menuOptionsMap={props.menuOptionsMap}
|
||||
onClickAction={props.onClickAction}
|
||||
onClickShowBanDialog={props.onClickShowBanDialog}/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
{userStatus === 'banned' ?
|
||||
<span className={styles.banned}><Icon name='error_outline'/> {lang.t('comment.banned_user')}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.flagCount}>
|
||||
{`${action.count} ${action.action_type === 'flag_bio' ? lang.t('user.bio_flags') : lang.t('user.username_flags')}`}
|
||||
</div>
|
||||
</li>;
|
||||
};
|
||||
|
||||
export default User;
|
||||
|
||||
const lang = new I18n(translations);
|
||||
@@ -1,6 +1,7 @@
|
||||
export const SHOW_BANUSER_DIALOG = 'SHOW_BANUSER_DIALOG';
|
||||
export const HIDE_BANUSER_DIALOG = 'HIDE_BANUSER_DIALOG';
|
||||
export const USERS_MODERATION_QUEUE_FETCH_SUCCESS = 'USERS_MODERATION_QUEUE_FETCH_SUCCESS';
|
||||
export const SHOW_SUSPENDUSER_DIALOG = 'SHOW_SUSPENDUSER_DIALOG';
|
||||
export const HIDE_SUSPENDUSER_DIALOG = 'HIDE_SUSPENDUSER_DIALOG';
|
||||
export const COMMENTS_MODERATION_QUEUE_FETCH_REQUEST = 'COMMENTS_MODERATION_QUEUE_FETCH_REQUEST';
|
||||
export const COMMENTS_MODERATION_QUEUE_FETCH_SUCCESS = 'COMMENTS_MODERATION_QUEUE_FETCH_SUCCESS';
|
||||
export const COMMENT_CREATE_SUCCESS = 'COMMENT_CREATE_SUCCESS';
|
||||
|
||||
@@ -5,3 +5,13 @@ export const SORT_UPDATE = 'SORT_UPDATE';
|
||||
export const COMMENTERS_NEW_PAGE = 'COMMENTERS_NEW_PAGE';
|
||||
export const SET_ROLE = 'SET_ROLE';
|
||||
export const SET_COMMENTER_STATUS = 'SET_COMMENTER_STATUS';
|
||||
|
||||
export const FETCH_FLAGGED_COMMENTERS_REQUEST = 'FETCH_FLAGGED_COMMENTERS_REQUEST';
|
||||
export const FETCH_FLAGGED_COMMENTERS_SUCCESS = 'FETCH_FLAGGED_COMMENTERS_SUCCESS';
|
||||
export const FETCH_FLAGGED_COMMENTERS_FAILURE = 'FETCH_FLAGGED_COMMENTERS_FAILURE';
|
||||
|
||||
export const SHOW_BANUSER_DIALOG = 'SHOW_BANUSER_DIALOG';
|
||||
export const HIDE_BANUSER_DIALOG = 'HIDE_BANUSER_DIALOG';
|
||||
|
||||
export const SHOW_SUSPENDUSER_DIALOG = 'SHOW_SUSPENDUSER_DIALOG';
|
||||
export const HIDE_SUSPENDUSER_DIALOG = 'HIDE_SUSPENDUSER_DIALOG';
|
||||
|
||||
@@ -3,4 +3,3 @@ export const UPDATE_STATUS_SUCCESS = 'UPDATE_STATUS_SUCCESS';
|
||||
export const UPDATE_STATUS_FAILURE = 'UPDATE_STATUS_FAILURE';
|
||||
export const USER_EMAIL_FAILURE = 'USER_EMAIL_FAILURE';
|
||||
export const USERNAME_ENABLE_FAILURE = 'USERNAME_ENABLE_FAILURE';
|
||||
export const USERS_MODERATION_QUEUE_FETCH_SUCCESS = 'USERS_MODERATION_QUEUE_FETCH_SUCCESS';
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
@custom-media --big-viewport (min-width: 780px);
|
||||
|
||||
.container {
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
@@ -15,6 +17,12 @@
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.mainFlaggedContent {
|
||||
width: 100%;
|
||||
padding: 34px 14px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.roleButton {
|
||||
display: block;
|
||||
}
|
||||
@@ -94,3 +102,232 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.list {
|
||||
padding: 8px 0;
|
||||
list-style: none;
|
||||
display: block;
|
||||
|
||||
&.singleView .listItem {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&.singleView .listItem.activeItem {
|
||||
display: block;
|
||||
height: 100%;
|
||||
font-size: 1.5em;
|
||||
line-height: 1.5em;
|
||||
border: none;
|
||||
|
||||
.actions {
|
||||
position: fixed;
|
||||
bottom: 60px;
|
||||
left: 25%;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
width: 50%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.actionButton {
|
||||
transform: scale(1.4);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.listItem {
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
font-size: 18px;
|
||||
width: 100%;
|
||||
max-width: 660px;
|
||||
min-width: 400px;
|
||||
margin: 0 auto;
|
||||
position: relative;
|
||||
transition: all 200ms;
|
||||
padding: 10px 0 0;
|
||||
min-height: 220px;
|
||||
|
||||
.container {
|
||||
display: block;
|
||||
padding: 0 14px;
|
||||
min-height: 180px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 3px 6px rgba(0,0,0,0.16), 0 3px 6px rgba(0,0,0,0.23);
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.context {
|
||||
a {
|
||||
color: #f36451;
|
||||
text-decoration: underline;
|
||||
float: right;
|
||||
}
|
||||
}
|
||||
|
||||
.sideActions {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
height: 100%;
|
||||
top: 0;
|
||||
padding: 40px 18px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.itemHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
.author {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
min-width: 230px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
.itemBody {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
margin-right: 16px;
|
||||
height: 40px;
|
||||
width: 40px;
|
||||
border-radius: 50%;
|
||||
background-color: #757575;
|
||||
font-size: 40px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.created {
|
||||
color: #666;
|
||||
font-size: 13px;
|
||||
margin-left: 40px;
|
||||
}
|
||||
|
||||
.actionButton {
|
||||
transform: scale(.8);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.body {
|
||||
margin-top: 0px;
|
||||
flex: 1;
|
||||
color: black;
|
||||
max-width: 500px;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.flagged {
|
||||
color: rgba(255, 0, 0, .5);
|
||||
padding-top: 15px;
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
.flagCount{
|
||||
font-size: 12px;
|
||||
color: #d32f2f;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: #444;
|
||||
margin-top: 50px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
|
||||
@media (--big-viewport) {
|
||||
.listItem {
|
||||
border: 1px solid #e0e0e0;
|
||||
margin-bottom: 30px;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
&.activeItem {
|
||||
border: 2px solid #333;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.hasLinks {
|
||||
color: #f00;
|
||||
text-align: right;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
i {
|
||||
margin-right: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
.banned {
|
||||
color: #f00;
|
||||
text-align: left;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
i {
|
||||
margin-right: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
.ban {
|
||||
display: block;
|
||||
text-align: center;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.banButton {
|
||||
width: 114px;
|
||||
letter-spacing: 1px;
|
||||
|
||||
i {
|
||||
vertical-align: middle;
|
||||
margin-right: 10px;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.actionButton {
|
||||
transform: scale(.8);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.flaggedByCount {
|
||||
display: block;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.flaggedBy {
|
||||
display: inline;
|
||||
padding: 3px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.flaggedByLabel {
|
||||
font-weight: bold;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.flaggedReasons {
|
||||
padding-top: 15px;
|
||||
margin-left: 24px;
|
||||
}
|
||||
|
||||
.flaggedByReason {
|
||||
font-size: 1tpx;
|
||||
}
|
||||
|
||||
@@ -1,14 +1,29 @@
|
||||
import React, {Component} from 'react';
|
||||
import {connect} from 'react-redux';
|
||||
import {compose} from 'react-apollo';
|
||||
|
||||
import {modUserFlaggedQuery} from 'coral-admin/src/graphql/queries';
|
||||
import {banUser, setUserStatus, suspendUser} from 'coral-admin/src/graphql/mutations';
|
||||
|
||||
import {
|
||||
fetchCommenters,
|
||||
fetchAccounts,
|
||||
updateSorting,
|
||||
newPage,
|
||||
showBanUserDialog,
|
||||
hideBanUserDialog,
|
||||
showSuspendUserDialog,
|
||||
hideSuspendUserDialog
|
||||
} from '../../actions/community';
|
||||
|
||||
import Community from './Community';
|
||||
import CommunityMenu from './components/CommunityMenu';
|
||||
import BanUserDialog from './components/BanUserDialog';
|
||||
import SuspendUserDialog from './components/SuspendUserDialog';
|
||||
|
||||
import People from './People';
|
||||
import FlaggedAccounts from './FlaggedAccounts';
|
||||
|
||||
class CommunityContainer extends Component {
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
@@ -22,6 +37,10 @@ class CommunityContainer extends Component {
|
||||
this.onNewPageHandler = this.onNewPageHandler.bind(this);
|
||||
}
|
||||
|
||||
componentWillMount() {
|
||||
this.props.fetchAccounts({});
|
||||
}
|
||||
|
||||
onKeyDownHandler(e) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
@@ -38,16 +57,13 @@ class CommunityContainer extends Component {
|
||||
search(query = {}) {
|
||||
const {community} = this.props;
|
||||
|
||||
this.props.dispatch(fetchCommenters({
|
||||
this.props.fetchAccounts({
|
||||
value: this.state.searchValue,
|
||||
field: community.field,
|
||||
asc: community.asc,
|
||||
field: community.fieldPeople,
|
||||
asc: community.ascPeople,
|
||||
...query
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
componentDidMount() {
|
||||
this.search();
|
||||
}
|
||||
|
||||
onHeaderClickHandler(sort) {
|
||||
@@ -60,19 +76,66 @@ class CommunityContainer extends Component {
|
||||
this.search({page});
|
||||
}
|
||||
|
||||
getTabContent(searchValue, props) {
|
||||
const {community, data} = props;
|
||||
const activeTab = props.route.path === ':id' ? 'flagged' : props.route.path;
|
||||
|
||||
if (activeTab === 'people') {
|
||||
return (
|
||||
<People
|
||||
isFetching={community.isFetchingPeople}
|
||||
commenters={community.accounts}
|
||||
searchValue={searchValue}
|
||||
error={community.errorPeople}
|
||||
totalPages={community.totalPagesPeople}
|
||||
page={community.pagePeople}
|
||||
onKeyDown={this.onKeyDownHandler}
|
||||
onChange={this.onChangeHandler}
|
||||
onHeaderClickHandler={this.onHeaderClickHandler}
|
||||
onNewPageHandler={this.onNewPageHandler}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<FlaggedAccounts
|
||||
commenters={data.users}
|
||||
isFetching={data.loading}
|
||||
error={data.error}
|
||||
showBanUserDialog={props.showBanUserDialog}
|
||||
approveUser={props.approveUser}
|
||||
suspendUser={props.suspendUser}
|
||||
showSuspendUserDialog={props.showSuspendUserDialog}
|
||||
/>
|
||||
<BanUserDialog
|
||||
open={community.banDialog}
|
||||
user={community.user}
|
||||
handleClose={props.hideBanUserDialog}
|
||||
handleBanUser={props.banUser}
|
||||
/>
|
||||
<SuspendUserDialog
|
||||
open={community.suspendDialog}
|
||||
handleClose={props.hideSuspendUserDialog}
|
||||
user={community.user}
|
||||
suspendUser={props.suspendUser}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const {searchValue} = this.state;
|
||||
const {community} = this.props;
|
||||
|
||||
const tab = this.getTabContent(searchValue, this.props);
|
||||
|
||||
return (
|
||||
<Community
|
||||
searchValue={searchValue}
|
||||
commenters={community.commenters}
|
||||
isFetching={community.isFetching}
|
||||
error={community.error}
|
||||
totalPages={community.totalPages}
|
||||
page={community.page}
|
||||
{...this}
|
||||
/>
|
||||
<div>
|
||||
<CommunityMenu />
|
||||
<div>
|
||||
{ tab }
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -81,4 +144,18 @@ const mapStateToProps = state => ({
|
||||
community: state.community.toJS()
|
||||
});
|
||||
|
||||
export default connect(mapStateToProps)(CommunityContainer);
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
fetchAccounts: query => dispatch(fetchAccounts(query)),
|
||||
showBanUserDialog: (user) => dispatch(showBanUserDialog(user)),
|
||||
hideBanUserDialog: () => dispatch(hideBanUserDialog(false)),
|
||||
showSuspendUserDialog: (user) => dispatch(showSuspendUserDialog(user)),
|
||||
hideSuspendUserDialog: () => dispatch(hideSuspendUserDialog())
|
||||
});
|
||||
|
||||
export default compose(
|
||||
connect(mapStateToProps, mapDispatchToProps),
|
||||
modUserFlaggedQuery,
|
||||
banUser,
|
||||
setUserStatus,
|
||||
suspendUser
|
||||
)(CommunityContainer);
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import React from 'react';
|
||||
|
||||
const CommunityLayout = props => (
|
||||
<div>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export default CommunityLayout;
|
||||
@@ -0,0 +1,44 @@
|
||||
import React from 'react';
|
||||
|
||||
import I18n from 'coral-framework/modules/i18n/i18n';
|
||||
import translations from 'coral-admin/src/translations.json';
|
||||
const lang = new I18n(translations);
|
||||
|
||||
import styles from './Community.css';
|
||||
|
||||
import Loading from './Loading';
|
||||
import EmptyCard from 'coral-admin/src/components/EmptyCard';
|
||||
import User from './components/User';
|
||||
|
||||
const FlaggedAccounts = ({...props}) => {
|
||||
const {commenters, isFetching} = props;
|
||||
const hasResults = !isFetching && commenters && !!commenters.length;
|
||||
|
||||
// if (commenter.status === 'PENDING' && commenter.actions.length > 0) {
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.mainFlaggedContent}>
|
||||
{ isFetching && <Loading /> }
|
||||
{
|
||||
hasResults
|
||||
? commenters.map((commenter, index) => {
|
||||
return <User
|
||||
user={commenter}
|
||||
key={index}
|
||||
index={index}
|
||||
modActionButtons={['APPROVE', 'REJECT']}
|
||||
showBanUserDialog={props.showBanUserDialog}
|
||||
showSuspendUserDialog={props.showSuspendUserDialog}
|
||||
approveUser={props.approveUser}
|
||||
suspendUser={props.suspendUser}
|
||||
/>;
|
||||
})
|
||||
: <EmptyCard>{lang.t('community.no-flagged-accounts')}</EmptyCard>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FlaggedAccounts;
|
||||
+3
-3
@@ -29,7 +29,7 @@ const tableHeaders = [
|
||||
}
|
||||
];
|
||||
|
||||
const Community = ({isFetching, commenters, ...props}) => {
|
||||
const People = ({isFetching, commenters, ...props}) => {
|
||||
const hasResults = !isFetching && !!commenters.length;
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
@@ -58,7 +58,7 @@ const Community = ({isFetching, commenters, ...props}) => {
|
||||
hasResults
|
||||
? <Table
|
||||
headers={tableHeaders}
|
||||
data={commenters}
|
||||
commenters={commenters}
|
||||
onHeaderClickHandler={props.onHeaderClickHandler}
|
||||
/>
|
||||
: <EmptyCard>{lang.t('community.no-results')}</EmptyCard>
|
||||
@@ -73,4 +73,4 @@ const Community = ({isFetching, commenters, ...props}) => {
|
||||
);
|
||||
};
|
||||
|
||||
export default Community;
|
||||
export default People;
|
||||
@@ -77,4 +77,4 @@ class Table extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(state => ({commenters: state.community.get('commenters')}))(Table);
|
||||
export default connect(state => ({commenters: state.community.get('accounts')}))(Table);
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import React from 'react';
|
||||
import styles from '../Community.css';
|
||||
import BanUserButton from '../../../components/BanUserButton';
|
||||
import {Button} from 'coral-ui';
|
||||
import {menuActionsMap} from '../../../containers/ModerationQueue/helpers/moderationQueueActionsMap';
|
||||
|
||||
const ActionButton = ({type = '', user, ...props}) => {
|
||||
if (type === 'BAN') {
|
||||
return <BanUserButton icon='not interested' user={user} onClick={() => props.showBanUserDialog(user)} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={`${type.toLowerCase()} ${styles.actionButton}`}
|
||||
cStyle={type.toLowerCase()}
|
||||
icon={menuActionsMap[type].icon}
|
||||
onClick={() => {
|
||||
type === 'APPROVE' ? props.approveUser({userId: user.id}) : props.showSuspendUserDialog({user: user});
|
||||
}}
|
||||
>{menuActionsMap[type].text}</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export default ActionButton;
|
||||
@@ -0,0 +1,164 @@
|
||||
.dialog {
|
||||
border: none;
|
||||
box-shadow: 0 9px 46px 8px rgba(0, 0, 0, 0.14), 0 11px 15px -7px rgba(0, 0, 0, 0.12), 0 24px 38px 3px rgba(0, 0, 0, 0.2);
|
||||
width: 500px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
height: 184px;
|
||||
padding: 20px;
|
||||
|
||||
h2 {
|
||||
color: black;
|
||||
font-size: 1.76em;
|
||||
font-weight: 500;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h3 {
|
||||
color: black;
|
||||
font-size: 1.4em;
|
||||
font-weight: 500;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.textField {
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.textField label {
|
||||
font-size: 1.08em;
|
||||
font-weight: bold;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.textField input {
|
||||
width: 100%;
|
||||
display: block;
|
||||
border: none;
|
||||
outline: none;
|
||||
border: 1px solid rgba(0,0,0,.12);
|
||||
padding: 10px 6px;
|
||||
box-sizing: border-box;
|
||||
border-radius: 2px;
|
||||
margin: 5px auto;
|
||||
}
|
||||
|
||||
.footer {
|
||||
margin: 20px auto 10px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.footer span {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.footer a {
|
||||
color: #2c69b6;
|
||||
cursor: pointer;
|
||||
margin: 0 5px;
|
||||
}
|
||||
|
||||
.socialConnections {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.signInButton {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.close {
|
||||
font-size: 20px;
|
||||
line-height: 14px;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
position: absolute;
|
||||
display: block;
|
||||
font-weight: bold;
|
||||
color: #363636;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.close:hover {
|
||||
color: #6b6b6b;
|
||||
}
|
||||
|
||||
input.error{
|
||||
border: solid 2px #f44336;
|
||||
}
|
||||
|
||||
.errorMsg, .hint {
|
||||
color: grey;
|
||||
font-weight: 600;
|
||||
padding: 3px 0 16px;
|
||||
}
|
||||
|
||||
.alert {
|
||||
padding: 10px;
|
||||
margin-bottom: 20px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.alert--success {
|
||||
border: solid 1px #1ec00e;
|
||||
background: #cbf1b8;
|
||||
color: #006900;
|
||||
}
|
||||
|
||||
.alert--error {
|
||||
background: #FFEBEE;
|
||||
color: #B71C1C;
|
||||
}
|
||||
|
||||
.userBox a {
|
||||
color: #2c69b6;
|
||||
cursor: pointer;
|
||||
margin: 0px;
|
||||
}
|
||||
|
||||
.attention {
|
||||
display: inline-block;
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
background: #B71C1C;
|
||||
color: #FFEBEE;
|
||||
font-weight: bolder;
|
||||
padding: 4px;
|
||||
vertical-align: middle;
|
||||
border-radius: 20px;
|
||||
box-sizing: border-box;
|
||||
font-size: 9px;
|
||||
line-height: 7px;
|
||||
text-align: center;
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.action {
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.passwordRequestSuccess {
|
||||
border: 1px solid green;
|
||||
background-color: lightgreen;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.passwordRequestFailure {
|
||||
border: 1px solid orange;
|
||||
background-color: 1px solid coral;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.cancel {
|
||||
margin-right: 10px;
|
||||
width: 47%;
|
||||
}
|
||||
|
||||
.ban {
|
||||
width: 47%;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
margin: 20px 0;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import React, {PropTypes} from 'react';
|
||||
import {Dialog} from 'coral-ui';
|
||||
import styles from './BanUserDialog.css';
|
||||
|
||||
import Button from 'coral-ui/components/Button';
|
||||
|
||||
import I18n from 'coral-framework/modules/i18n/i18n';
|
||||
import translations from 'coral-admin/src/translations.json';
|
||||
|
||||
const lang = new I18n(translations);
|
||||
|
||||
const BanUserDialog = ({open, handleClose, handleBanUser, user}) => (
|
||||
<Dialog
|
||||
className={styles.dialog}
|
||||
id="banuserDialog"
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
onCancel={handleClose}
|
||||
title={lang.t('community.ban_user')}>
|
||||
<span className={styles.close} onClick={handleClose}>×</span>
|
||||
<div>
|
||||
<div className={styles.header}>
|
||||
<h2>{lang.t('community.ban_user')}</h2>
|
||||
</div>
|
||||
<div className={styles.separator}>
|
||||
<h3>{lang.t('community.are_you_sure', user.username)}</h3>
|
||||
<i>{lang.t('community.note')}</i>
|
||||
</div>
|
||||
<div className={styles.buttons}>
|
||||
<Button cStyle="cancel" className={styles.cancel} onClick={handleClose} raised>
|
||||
{lang.t('community.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
cStyle="black" className={styles.ban}
|
||||
onClick={() => {
|
||||
handleBanUser({userId: user.id}).then(() => {
|
||||
handleClose();
|
||||
});
|
||||
}}
|
||||
raised>
|
||||
{lang.t('community.yes_ban_user')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
BanUserDialog.propTypes = {
|
||||
handleBanUser: PropTypes.func.isRequired,
|
||||
handleClose: PropTypes.func.isRequired,
|
||||
user: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
export default BanUserDialog;
|
||||
@@ -0,0 +1,31 @@
|
||||
import React from 'react';
|
||||
|
||||
import styles from './styles.css';
|
||||
|
||||
import I18n from 'coral-framework/modules/i18n/i18n';
|
||||
import translations from 'coral-admin/src/translations.json';
|
||||
|
||||
import {Link} from 'react-router';
|
||||
|
||||
const lang = new I18n(translations);
|
||||
|
||||
const CommunityMenu = () => {
|
||||
const flaggedPath = '/admin/community/flagged';
|
||||
const peoplePath = '/admin/community/people';
|
||||
return (
|
||||
<div className='mdl-tabs'>
|
||||
<div className={`mdl-tabs__tab-bar ${styles.tabBar}`}>
|
||||
<div>
|
||||
<Link to={flaggedPath} className={`mdl-tabs__tab ${styles.tab}`} activeClassName={styles.active}>
|
||||
{lang.t('community.flaggedaccounts')}
|
||||
</Link>
|
||||
<Link to={peoplePath} className={`mdl-tabs__tab ${styles.tab}`} activeClassName={styles.active}>
|
||||
{lang.t('community.people')}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CommunityMenu;
|
||||
@@ -0,0 +1,115 @@
|
||||
import React, {Component, PropTypes} from 'react';
|
||||
|
||||
import {Dialog, Button} from 'coral-ui';
|
||||
import styles from './SuspendUserDialog.css';
|
||||
|
||||
import I18n from 'coral-framework/modules/i18n/i18n';
|
||||
import translations from 'coral-admin/src/translations.json';
|
||||
|
||||
const lang = new I18n(translations);
|
||||
|
||||
const stages = [
|
||||
{
|
||||
title: 'suspenduser.title_0',
|
||||
description: 'suspenduser.description_0',
|
||||
options: {
|
||||
'j': 'suspenduser.no_cancel',
|
||||
'k': 'suspenduser.yes_suspend'
|
||||
}
|
||||
},
|
||||
{
|
||||
title: 'suspenduser.title_1',
|
||||
description: 'suspenduser.description_1',
|
||||
options: {
|
||||
'j': 'bandialog.cancel',
|
||||
'k': 'suspenduser.send'
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
class SuspendUserDialog extends Component {
|
||||
|
||||
state = {email: '', stage: 0}
|
||||
|
||||
static propTypes = {
|
||||
stage: PropTypes.number,
|
||||
handleClose: PropTypes.func.isRequired,
|
||||
suspendUser: PropTypes.func.isRequired
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.setState({email: lang.t('suspenduser.email'), about: lang.t('suspenduser.username')});
|
||||
}
|
||||
|
||||
/*
|
||||
* When an admin clicks to suspend a user a dialog is shown, this function
|
||||
* handles the possible actions for that dialog.
|
||||
*/
|
||||
onActionClick = (stage, menuOption) => () => {
|
||||
const {suspendUser, user} = this.props;
|
||||
const {stage} = this.state;
|
||||
|
||||
const cancel = this.props.handleClose;
|
||||
const next = () => this.setState({stage: stage + 1});
|
||||
const suspend = () => {
|
||||
suspendUser({userId: user.user.id, message: this.state.email})
|
||||
.then(() => {
|
||||
this.props.handleClose();
|
||||
});
|
||||
};
|
||||
|
||||
const suspendModalActions = [
|
||||
[ cancel, next ],
|
||||
[ cancel, suspend ]
|
||||
];
|
||||
return suspendModalActions[stage][menuOption]();
|
||||
}
|
||||
|
||||
onEmailChange = (e) => {
|
||||
this.setState({email: e.target.value});
|
||||
}
|
||||
|
||||
render () {
|
||||
const {open, handleClose} = this.props;
|
||||
const {stage} = this.state;
|
||||
|
||||
return <Dialog
|
||||
className={styles.suspendDialog}
|
||||
id="suspendUserDialog"
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
onCancel={handleClose}
|
||||
title={lang.t('suspenduser.title')}>
|
||||
<div className={styles.title}>
|
||||
{lang.t(stages[stage].title, lang.t('suspenduser.username'))}
|
||||
</div>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.description}>
|
||||
{lang.t(stages[stage].description, lang.t('suspenduser.username'))}
|
||||
</div>
|
||||
{
|
||||
stage === 1 &&
|
||||
<div className={styles.writeContainer}>
|
||||
<div className={styles.emailMessage}>{lang.t('suspenduser.write_message')}</div>
|
||||
<div className={styles.emailContainer}>
|
||||
<textarea
|
||||
rows={5}
|
||||
className={styles.emailInput}
|
||||
value={this.state.email}
|
||||
onChange={this.onEmailChange}/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<div className={styles.modalButtons}>
|
||||
{Object.keys(stages[stage].options).map((key, i) => (
|
||||
<Button key={i} onClick={this.onActionClick(stage, i)}>
|
||||
{lang.t(stages[stage].options[key], lang.t('suspenduser.username'))}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>;
|
||||
}
|
||||
}
|
||||
|
||||
export default SuspendUserDialog;
|
||||
@@ -0,0 +1,90 @@
|
||||
import React from 'react';
|
||||
import styles from '../Community.css';
|
||||
|
||||
import ActionButton from './ActionButton';
|
||||
|
||||
import I18n from 'coral-framework/modules/i18n/i18n';
|
||||
import translations from '../../../translations.json';
|
||||
|
||||
const lang = new I18n(translations);
|
||||
|
||||
// Render a single user for the list
|
||||
const User = props => {
|
||||
const {user, modActionButtons} = props;
|
||||
let userStatus = user.status;
|
||||
|
||||
// Do not display unless the user status is 'pending' or 'banned'.
|
||||
// This means that they have already been reviewed and approved.
|
||||
return (userStatus === 'PENDING' || userStatus === 'BANNED') &&
|
||||
<li tabIndex={props.index} className={`mdl-card ${props.selected ? 'mdl-shadow--8dp' : 'mdl-shadow--2dp'} ${styles.listItem} ${props.isActive && !props.hideActive ? styles.activeItem : ''}`}>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.itemHeader}>
|
||||
<div className={styles.author}>
|
||||
<span>
|
||||
{user.username}
|
||||
</span>
|
||||
<ActionButton
|
||||
className={styles.banButton}
|
||||
type='BAN'
|
||||
user={user}
|
||||
showBanUserDialog={props.showBanUserDialog}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.itemBody}>
|
||||
<div className={styles.body}>
|
||||
<div className={styles.flaggedByCount}>
|
||||
<i className="material-icons">flag</i><span className={styles.flaggedByLabel}>{lang.t('community.flags')}({ user.actions.length })</span>:
|
||||
{ user.action_summaries.map(
|
||||
(action, i ) => {
|
||||
return <span className={styles.flaggedBy} key={i}>
|
||||
{lang.t(`community.${action.reason}`)} ({action.count})
|
||||
</span>;
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.flaggedReasons}>
|
||||
{ user.action_summaries.map(
|
||||
(action_sum, i ) => {
|
||||
return <div key={i}>
|
||||
<span className={styles.flaggedByLabel}>
|
||||
{lang.t(`community.${action_sum.reason}`)} ({action_sum.count})
|
||||
</span>
|
||||
{user.actions.map(
|
||||
|
||||
// find the action by action_sum.reason
|
||||
(action, j) => {
|
||||
if (action.reason === action_sum.reason) {
|
||||
return <p className={styles.flaggedByReason} key={j}>
|
||||
{action.user && action.user.username}: {action.message ? action.message : 'n/a'}
|
||||
</p>;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
)}
|
||||
</div>;
|
||||
}
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.sideActions}>
|
||||
<div className={`actions ${styles.actions}`}>
|
||||
{modActionButtons.map((action, i) =>
|
||||
<ActionButton key={i}
|
||||
type={action.toUpperCase()}
|
||||
user={user}
|
||||
approveUser={props.approveUser}
|
||||
suspendUser={props.suspendUser}
|
||||
showSuspendUserDialog={props.showSuspendUserDialog}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>;
|
||||
};
|
||||
|
||||
export default User;
|
||||
@@ -0,0 +1,339 @@
|
||||
@custom-media --big-viewport (min-width: 780px);
|
||||
|
||||
.listContainer {
|
||||
max-width: 860px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.tabBar {
|
||||
background-color: rgba(44, 44, 44, 0.89);
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.tab {
|
||||
flex: 1;
|
||||
color: white;
|
||||
text-transform: capitalize;
|
||||
font-weight: 500;
|
||||
font-size: 15px;
|
||||
letter-spacing: 1px;
|
||||
transition: border-bottom 200ms;
|
||||
}
|
||||
|
||||
.active {
|
||||
color: white;
|
||||
box-sizing: border-box;
|
||||
border-bottom: solid 5px #F36451;
|
||||
}
|
||||
|
||||
.active > span {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.active:after {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.showShortcuts {
|
||||
position: absolute;
|
||||
right: 130px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 13px;
|
||||
|
||||
span {
|
||||
margin-left: 7px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (--big-viewport) {
|
||||
.tab {
|
||||
flex: none;
|
||||
}
|
||||
}
|
||||
|
||||
.notFound {
|
||||
position: relative;
|
||||
margin: 20px auto;
|
||||
text-align: center;
|
||||
padding: 68px 45px;
|
||||
vertical-align: middle;
|
||||
min-width: 500px;
|
||||
|
||||
a {
|
||||
color: rgb(244, 126, 107);
|
||||
font-weight: 500;
|
||||
|
||||
&.goToStreams {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
bottom: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.header {
|
||||
background-color: #2c2c2c;
|
||||
color: white;
|
||||
margin-bottom: -1px;
|
||||
|
||||
.settingsButton {
|
||||
i {
|
||||
vertical-align: middle;
|
||||
margin-left: 10px;
|
||||
margin-top: -4px;
|
||||
}
|
||||
}
|
||||
|
||||
.moderateAsset {
|
||||
a {
|
||||
-webkit-box-flex: 1;
|
||||
-ms-flex: 1;
|
||||
flex: 1;
|
||||
color: white;
|
||||
text-transform: capitalize;
|
||||
font-weight: 500;
|
||||
font-size: 15px;
|
||||
letter-spacing: 1px;
|
||||
transition: opacity 200ms;
|
||||
opacity: 1;
|
||||
|
||||
&:hover {
|
||||
opacity: .8;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&:first-child {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
&:nth-child(2) {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@custom-media --big-viewport (min-width: 780px);
|
||||
|
||||
.list {
|
||||
padding: 8px 0;
|
||||
list-style: none;
|
||||
display: block;
|
||||
|
||||
&.singleView .listItem {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&.singleView .listItem.activeItem {
|
||||
display: block;
|
||||
height: 100%;
|
||||
font-size: 1.5em;
|
||||
line-height: 1.5em;
|
||||
border: none;
|
||||
|
||||
.actions {
|
||||
position: fixed;
|
||||
bottom: 60px;
|
||||
left: 25%;
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
width: 50%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.actionButton {
|
||||
transform: scale(1.4);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.listItem {
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
font-size: 18px;
|
||||
width: 100%;
|
||||
max-width: 660px;
|
||||
min-width: 400px;
|
||||
margin: 0 auto;
|
||||
position: relative;
|
||||
transition: all 200ms;
|
||||
padding: 10px 0 0;
|
||||
min-height: 220px;
|
||||
|
||||
.container {
|
||||
padding: 0 14px;
|
||||
min-height: 180px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 3px 6px rgba(0,0,0,0.16), 0 3px 6px rgba(0,0,0,0.23);
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.context {
|
||||
a {
|
||||
color: #f36451;
|
||||
text-decoration: underline;
|
||||
float: right;
|
||||
}
|
||||
}
|
||||
|
||||
.sideActions {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
height: 100%;
|
||||
top: 0;
|
||||
padding: 40px 18px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.itemHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
.author {
|
||||
min-width: 230px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
.itemBody {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
margin-right: 16px;
|
||||
height: 40px;
|
||||
width: 40px;
|
||||
border-radius: 50%;
|
||||
background-color: #757575;
|
||||
font-size: 40px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.created {
|
||||
color: #666;
|
||||
font-size: 13px;
|
||||
margin-left: 40px;
|
||||
}
|
||||
|
||||
.actionButton {
|
||||
transform: scale(.8);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.body {
|
||||
margin-top: 0px;
|
||||
flex: 1;
|
||||
color: black;
|
||||
max-width: 500px;
|
||||
word-wrap: break-word;
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
.flagged {
|
||||
color: rgba(255, 0, 0, .5);
|
||||
padding-top: 15px;
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
.flagCount{
|
||||
font-size: 12px;
|
||||
color: #d32f2f;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: #444;
|
||||
margin-top: 50px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
|
||||
@media (--big-viewport) {
|
||||
.listItem {
|
||||
border: 1px solid #e0e0e0;
|
||||
margin-bottom: 30px;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
&.activeItem {
|
||||
border: 2px solid #333;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.hasLinks {
|
||||
color: #f00;
|
||||
text-align: right;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
i {
|
||||
margin-right: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
.banned {
|
||||
color: #f00;
|
||||
text-align: left;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
i {
|
||||
margin-right: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
.ban {
|
||||
display: block;
|
||||
text-align: center;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.Comment {
|
||||
.moderateArticle {
|
||||
font-size: 12px;
|
||||
a {
|
||||
display: inline-block;
|
||||
color: #679af3;
|
||||
text-decoration: none;
|
||||
font-size: 1em;
|
||||
font-weight: 400;
|
||||
letter-spacing: .5px;
|
||||
font-size: 12px;
|
||||
margin-left: 10px;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
opacity: .9;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.flagBox {
|
||||
max-width: 480px;
|
||||
border-top: 1px solid rgba(66, 66, 66, 0.12);
|
||||
h3 {
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
@@ -73,7 +73,6 @@
|
||||
.configSettingInfoBox {
|
||||
min-height: 100px;
|
||||
margin-bottom: 20px;
|
||||
cursor: pointer;
|
||||
width: auto;
|
||||
height: auto;
|
||||
text-align: left;
|
||||
@@ -180,7 +179,9 @@
|
||||
|
||||
.content {
|
||||
display: inline-block;
|
||||
padding-left: 30px;
|
||||
padding: 0px 30px;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,12 +193,11 @@
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
width: 550px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.descriptionBox {
|
||||
margin-top: 15px;
|
||||
max-width: 550px;
|
||||
|
||||
input {
|
||||
height: 150px;
|
||||
|
||||
@@ -5,6 +5,7 @@ import translations from '../../translations.json';
|
||||
import styles from './Configure.css';
|
||||
import {Checkbox, Textfield} from 'react-mdl';
|
||||
import {Card, Icon, TextArea} from 'coral-ui';
|
||||
import MarkdownEditor from 'coral-admin/src/components/MarkdownEditor';
|
||||
|
||||
const TIMESTAMPS = {
|
||||
weeks: 60 * 60 * 24 * 7,
|
||||
@@ -32,11 +33,15 @@ const updateInfoBoxEnable = (updateSettings, infoBox) => () => {
|
||||
updateSettings({infoBoxEnable});
|
||||
};
|
||||
|
||||
const updateInfoBoxContent = (updateSettings) => (event) => {
|
||||
const infoBoxContent = event.target.value;
|
||||
const updateInfoBoxContent = (updateSettings) => (value) => {
|
||||
const infoBoxContent = value;
|
||||
updateSettings({infoBoxContent});
|
||||
};
|
||||
|
||||
const updateAutoClose = (updateSettings, autoCloseStream) => () => {
|
||||
updateSettings({autoCloseStream});
|
||||
};
|
||||
|
||||
const updateClosedMessage = (updateSettings) => (event) => {
|
||||
const closedMessage = event.target.value;
|
||||
updateSettings({closedMessage});
|
||||
@@ -108,19 +113,17 @@ const StreamSettings = ({updateSettings, settingsError, settings, errors}) => {
|
||||
{lang.t('configure.include-comment-stream-desc')}
|
||||
</p>
|
||||
<div className={`${styles.configSettingInfoBox} ${settings.infoBoxEnable ? null : styles.hidden}`} >
|
||||
<div>
|
||||
<TextArea
|
||||
className={styles.descriptionBox}
|
||||
onChange={updateInfoBoxContent(updateSettings)}
|
||||
value={settings.infoBoxContent}
|
||||
/>
|
||||
</div>
|
||||
<MarkdownEditor
|
||||
className={styles.descriptionBox}
|
||||
onChange={updateInfoBoxContent(updateSettings)}
|
||||
value={settings.infoBoxContent}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card className={`${styles.configSetting} ${styles.configSettingInfoBox}`}>
|
||||
<div className={styles.settingsHeader}>{lang.t('configure.closed-stream-settings')}</div>
|
||||
<div className={styles.wrapper}>
|
||||
<div className={styles.settingsHeader}>{lang.t('configure.closed-stream-settings')}</div>
|
||||
<p>{lang.t('configure.closed-comments-desc')}</p>
|
||||
<div>
|
||||
<TextArea className={styles.descriptionBox}
|
||||
@@ -131,6 +134,11 @@ const StreamSettings = ({updateSettings, settingsError, settings, errors}) => {
|
||||
</div>
|
||||
</Card>
|
||||
<Card className={`${styles.configSetting} ${styles.configSettingInfoBox}`}>
|
||||
<div className={styles.action}>
|
||||
<Checkbox
|
||||
onChange={updateAutoClose(updateSettings, !settings.autoCloseStream)}
|
||||
checked={settings.autoCloseStream} />
|
||||
</div>
|
||||
<div className={styles.content}>
|
||||
{lang.t('configure.close-after')}
|
||||
<br />
|
||||
|
||||
@@ -22,19 +22,19 @@ const Comment = ({actions = [], ...props}) => {
|
||||
const linkText = links ? links.map(link => link.raw) : [];
|
||||
const actionSummaries = props.comment.action_summaries;
|
||||
return (
|
||||
<li tabIndex={props.index} className={`mdl-card ${props.selected ? 'mdl-shadow--8dp' : 'mdl-shadow--2dp'} ${styles.Comment} ${styles.listItem} ${props.selected ? styles.selected : ''}`}>
|
||||
<li tabIndex={props.index} className={`mdl-card ${props.selected ? 'mdl-shadow--8dp' : 'mdl-shadow--2dp'} ${styles.Comment} ${styles.listItem}`}>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.itemHeader}>
|
||||
<div className={styles.author}>
|
||||
<span>
|
||||
{props.comment.user.name}
|
||||
</span>
|
||||
<span className={styles.created}>
|
||||
{timeago().format(props.comment.created_at || (Date.now() - props.index * 60 * 1000), lang.getLocale().replace('-', '_'))}
|
||||
</span>
|
||||
<BanUserButton user={props.comment.user} onClick={() => props.showBanUserDialog(props.comment.user, props.comment.id, props.comment.status !== 'REJECTED')} />
|
||||
<CommentType type={props.commentType} />
|
||||
</div>
|
||||
<span>
|
||||
{props.comment.user.name}
|
||||
</span>
|
||||
<span className={styles.created}>
|
||||
{timeago().format(props.comment.created_at || (Date.now() - props.index * 60 * 1000), lang.getLocale().replace('-', '_'))}
|
||||
</span>
|
||||
<BanUserButton user={props.comment.user} onClick={() => props.showBanUserDialog(props.comment.user, props.comment.id, props.comment.status !== 'REJECTED')} />
|
||||
<CommentType type={props.commentType} />
|
||||
</div>
|
||||
{props.comment.user.status === 'banned' ?
|
||||
<span className={styles.banned}>
|
||||
<Icon name='error_outline'/>
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import React, { PropTypes } from 'react';
|
||||
import React, {PropTypes} from 'react';
|
||||
import CommentCount from './CommentCount';
|
||||
import styles from './styles.css';
|
||||
import { SelectField, Option } from 'react-mdl-selectfield';
|
||||
import {SelectField, Option} from 'react-mdl-selectfield';
|
||||
import I18n from 'coral-framework/modules/i18n/i18n';
|
||||
import translations from 'coral-admin/src/translations.json';
|
||||
import { Link } from 'react-router';
|
||||
import {Link} from 'react-router';
|
||||
|
||||
const lang = new I18n(translations);
|
||||
|
||||
const ModerationMenu = (
|
||||
{ asset, premodCount, rejectedCount, flaggedCount, selectSort, sort }
|
||||
{asset, premodCount, rejectedCount, flaggedCount, selectSort, sort}
|
||||
) => {
|
||||
const premodPath = asset
|
||||
? `/admin/moderate/premod/${asset.id}`
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {graphql} from 'react-apollo';
|
||||
import SET_USER_STATUS from './setUserStatus.graphql';
|
||||
import SET_COMMENT_STATUS from './setCommentStatus.graphql';
|
||||
import SUSPEND_USER from './suspendUser.graphql';
|
||||
|
||||
export const banUser = graphql(SET_USER_STATUS, {
|
||||
props: ({mutate}) => ({
|
||||
@@ -9,11 +10,40 @@ export const banUser = graphql(SET_USER_STATUS, {
|
||||
variables: {
|
||||
userId,
|
||||
status: 'BANNED'
|
||||
}
|
||||
},
|
||||
refetchQueries: ['Users']
|
||||
});
|
||||
}}),
|
||||
});
|
||||
|
||||
export const setUserStatus = graphql(SET_USER_STATUS, {
|
||||
props: ({mutate}) => ({
|
||||
approveUser: ({userId}) => {
|
||||
return mutate({
|
||||
variables: {
|
||||
userId,
|
||||
status: 'APPROVED'
|
||||
},
|
||||
refetchQueries: ['Users']
|
||||
});
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
export const suspendUser = graphql(SUSPEND_USER, {
|
||||
props: ({mutate}) => ({
|
||||
suspendUser: ({userId, message}) => {
|
||||
return mutate({
|
||||
variables: {
|
||||
userId,
|
||||
message
|
||||
},
|
||||
refetchQueries: ['Users']
|
||||
});
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
export const setCommentStatus = graphql(SET_COMMENT_STATUS, {
|
||||
props: ({mutate}) => ({
|
||||
acceptComment: ({commentId}) => {
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
mutation suspendUser($userId: ID!, $message: String) {
|
||||
suspendUser(id: $userId, message: $message) {
|
||||
errors {
|
||||
translation_key
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import {graphql} from 'react-apollo';
|
||||
|
||||
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';
|
||||
|
||||
export const modQueueQuery = graphql(MOD_QUEUE_QUERY, {
|
||||
@@ -66,6 +67,16 @@ export const loadMore = (fetchMore) => ({limit, cursor, sort, tab, asset_id}) =>
|
||||
});
|
||||
};
|
||||
|
||||
export const modUserFlaggedQuery = graphql(MOD_USER_FLAGGED_QUERY, {
|
||||
options: ({params: {action_type = 'FLAG'}}) => {
|
||||
return {
|
||||
variables: {
|
||||
action_type: action_type
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
export const modQueueResort = (id, fetchMore) => (sort) => {
|
||||
return fetchMore({
|
||||
query: MOD_QUEUE_QUERY,
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
query Users ($action_type: ACTION_TYPE) {
|
||||
users (query:{action_type: $action_type}){
|
||||
id
|
||||
username
|
||||
status
|
||||
roles
|
||||
actions{
|
||||
id
|
||||
created_at
|
||||
... on FlagAction {
|
||||
reason
|
||||
message
|
||||
user {
|
||||
id
|
||||
username
|
||||
}
|
||||
}
|
||||
}
|
||||
action_summaries {
|
||||
count
|
||||
... on FlagActionSummary {
|
||||
reason
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,6 @@ import store from './services/store';
|
||||
|
||||
import App from './components/App';
|
||||
|
||||
import 'react-mdl/extra/material.css';
|
||||
import 'react-mdl/extra/material.js';
|
||||
|
||||
render(
|
||||
|
||||
@@ -6,58 +6,88 @@ import {
|
||||
FETCH_COMMENTERS_SUCCESS,
|
||||
SORT_UPDATE,
|
||||
SET_ROLE,
|
||||
SET_COMMENTER_STATUS
|
||||
SET_COMMENTER_STATUS,
|
||||
SHOW_BANUSER_DIALOG,
|
||||
HIDE_BANUSER_DIALOG,
|
||||
SHOW_SUSPENDUSER_DIALOG,
|
||||
HIDE_SUSPENDUSER_DIALOG
|
||||
} from '../constants/community';
|
||||
|
||||
const initialState = Map({
|
||||
community: Map(),
|
||||
isFetching: false,
|
||||
error: '',
|
||||
commenters: [],
|
||||
field: 'created_at',
|
||||
asc: false,
|
||||
totalPages: 0,
|
||||
page: 0
|
||||
isFetchingPeople: false,
|
||||
errorPeople: '',
|
||||
accounts: [],
|
||||
fieldPeople: 'created_at',
|
||||
ascPeople: false,
|
||||
totalPagesPeople: 0,
|
||||
pagePeople: 0,
|
||||
user: Map({}),
|
||||
banDialog: false,
|
||||
suspendDialog: false
|
||||
});
|
||||
|
||||
export default function community (state = initialState, action) {
|
||||
switch (action.type) {
|
||||
case FETCH_COMMENTERS_REQUEST :
|
||||
return state
|
||||
.set('isFetching', true);
|
||||
.set('isFetchingPeople', true);
|
||||
case FETCH_COMMENTERS_FAILURE :
|
||||
return state
|
||||
.set('isFetching', false)
|
||||
.set('error', action.error);
|
||||
.set('isFetchingPeople', false)
|
||||
.set('errorPeople', action.error);
|
||||
|
||||
case FETCH_COMMENTERS_SUCCESS : {
|
||||
const {commenters, type, ...rest} = action; // eslint-disable-line
|
||||
const {accounts, type, page, count, limit, totalPages, ...rest} = action; // eslint-disable-line
|
||||
return state
|
||||
.merge({
|
||||
isFetching: false,
|
||||
error: '',
|
||||
isFetchingPeople: false,
|
||||
errorPeople: '',
|
||||
pagePeople: page,
|
||||
countPeople: count,
|
||||
limitPeople: limit,
|
||||
totalPagesPeople: totalPages,
|
||||
...rest
|
||||
})
|
||||
.set('commenters', commenters); // Sets to normal array
|
||||
.set('accounts', accounts); // Sets to normal array
|
||||
}
|
||||
case SET_ROLE : {
|
||||
const commenters = state.get('commenters');
|
||||
const commenters = state.get('accounts');
|
||||
const idx = commenters.findIndex(el => el.id === action.id);
|
||||
|
||||
commenters[idx].roles[0] = action.role;
|
||||
return state.set('commenters', commenters.map(id => id));
|
||||
return state.set('accounts', commenters.map(id => id));
|
||||
}
|
||||
case SET_COMMENTER_STATUS: {
|
||||
const commenters = state.get('commenters');
|
||||
const commenters = state.get('accounts');
|
||||
const idx = commenters.findIndex(el => el.id === action.id);
|
||||
|
||||
commenters[idx].status = action.status;
|
||||
return state.set('commenters', commenters.map(id => id));
|
||||
return state.set('accounts', commenters.map(id => id));
|
||||
|
||||
}
|
||||
case SORT_UPDATE :
|
||||
return state
|
||||
.set('field', action.sort.field)
|
||||
.set('asc', !state.get('asc'));
|
||||
.set('fieldPeople', action.sort.field)
|
||||
.set('ascPeople', !state.get('ascPeople'));
|
||||
case HIDE_BANUSER_DIALOG:
|
||||
return state
|
||||
.set('banDialog', false);
|
||||
case SHOW_BANUSER_DIALOG:
|
||||
return state
|
||||
.merge({
|
||||
user: Map(action.user),
|
||||
banDialog: true
|
||||
});
|
||||
case HIDE_SUSPENDUSER_DIALOG:
|
||||
return state
|
||||
.set('suspendDialog', false);
|
||||
case SHOW_SUSPENDUSER_DIALOG:
|
||||
return state
|
||||
.merge({
|
||||
user: Map(action.user),
|
||||
suspendDialog: true
|
||||
});
|
||||
default :
|
||||
return state;
|
||||
}
|
||||
|
||||
@@ -17,7 +17,21 @@
|
||||
"active": "Active",
|
||||
"banned": "Banned",
|
||||
"banned-user": "Banned User",
|
||||
"loading": "Loading results"
|
||||
"loading": "Loading results",
|
||||
"flags": "Flags",
|
||||
"flaggedaccounts": "Flagged Usernames",
|
||||
"people": "People",
|
||||
"no-flagged-accounts": "The Account Flags queue is currently empty.",
|
||||
"I don't like this username": "I don't like this username",
|
||||
"This user is impersonating": "Impersonation",
|
||||
"This looks like an ad/marketing": "Spam/Ads",
|
||||
"This username is offensive": "Offensive",
|
||||
"Other": "Other",
|
||||
"ban_user": "Ban User?",
|
||||
"are_you_sure": "Are you sure you would like to ban {0}?",
|
||||
"note": "Note: Banning this user will not let them edit, comment or remove anything.",
|
||||
"cancel": "Cancel",
|
||||
"yes_ban_user": "Yes, Ban User"
|
||||
},
|
||||
"modqueue": {
|
||||
"likes": "likes",
|
||||
@@ -104,8 +118,9 @@
|
||||
"yes_ban_user": "Yes, Ban User"
|
||||
},
|
||||
"suspenduser": {
|
||||
"title_0": "We noticed you rejected a {0}",
|
||||
"description_0": "Would you like to temporarily ban this user becuase of their {0}? Doing so will temporarily hide their comments until they rewrite their {0}.",
|
||||
"title": "Suspend a user",
|
||||
"title_0": "We noticed you rejected a username",
|
||||
"description_0": "Would you like to temporarily ban this user because of their {0}? Doing so will temporarily hide their comments until they rewrite their {0}.",
|
||||
"title_1": "Notify the user of their temporary suspension",
|
||||
"description_1": "Suspending this user will temporarily disable their account and hide all of their comments on the site.",
|
||||
"no_cancel": "No, cancel",
|
||||
@@ -114,7 +129,7 @@
|
||||
"bio": "bio",
|
||||
"username": "username",
|
||||
"email_subject": "Your account has been suspended",
|
||||
"email": "Another member of the community recently flagged your {0} for review. Because of its content your {0} was rejected. This means you can no longer comment, like, or flag content until you rewrite your {0}. Please e-mail moderator@newsorg.com if you have any questions or concerns.",
|
||||
"email": "Another member of the community recently flagged your username for review. Because of its content your user was rejected. This means you can no longer comment, like, or flag content until you rewrite your username. Please e-mail us if you have any questions or concerns.",
|
||||
"write_message": "Write a message"
|
||||
},
|
||||
"dashboard": {
|
||||
@@ -163,6 +178,7 @@
|
||||
"banned": "Suspendido",
|
||||
"banned-user": "Usuario Suspendido",
|
||||
"loading": "Cargando resultados",
|
||||
"flags": "Reporte",
|
||||
"flaggedaccounts": "Nombres de Usuario Reportados",
|
||||
"people": "Gente",
|
||||
"no-flagged-accounts": "No hay ninguna cuenta reportada.",
|
||||
@@ -179,16 +195,16 @@
|
||||
},
|
||||
"suspenduser": {
|
||||
"title": "Suspendiendo un usuario",
|
||||
"title_0": "¿Esta queriendo suspender un usuario?",
|
||||
"description_0": "¿Le gustaría suspender a esta usuaria temporalmente devido a su nombre de usuario? Si lo hace sus comentarios serán escondidos temporalmente hasta que el usuario pueda cambiar su nombre.",
|
||||
"title_1": "Enviarle un correo al usuario informandole que su cuenta fue suspendida",
|
||||
"title_0": "Esta queriendo suspender un usuario?",
|
||||
"description_0": "Le gustaria suspender a esta usuaria temporarianmente por su nombre de usuario? Si lo hace sus comentarios serán escondidos temporariamente hasta que puedan reescribir su nombre de usuario.",
|
||||
"title_1": "Enviarle una nota al usuario sobre su cuenta suspendida",
|
||||
"description_1": "Si suspende a este usuario, su cuenta va a ser deshabilitada y todos sus comentarios escondidos del sitio.",
|
||||
"no_cancel": "No, cancelar",
|
||||
"yes_suspend": "Si, suspender",
|
||||
"send": "Enviar",
|
||||
"username": "nombre de usuario",
|
||||
"email_subject": "Su cuenta ha sido suspendida temporalmente",
|
||||
"email": "Una persona de la comunidad recientemente marcó su nombre de usuario para ser revisado. Por su contenido, el nombre de usuario ha sido rechazado. Esto quiere decir que no puede comentar, gustar o marcar contenido hasta que modifique su nombre de usuario. Por favor, envienos un correo si tiene alguna pregunta o comentario.",
|
||||
"email_subject": "Su cuenta ha sido suspendida temporariamente",
|
||||
"email": "Otra persona de la comunidad recientemente marcó su nombre de usuario para ser revisado. Por su contenido, el nombre de usuario ha sido rechazado. Esto quiere decir que no puede comentar, gustar o marcar contenido hasta que modifique su nombre de usuario. Por favor, envienos un correo a moderator@newsorg.com si tiene alguna pregunta o preocupación",
|
||||
"write_message": "Escribir un mensaje",
|
||||
"loading": "Cargando resultados"
|
||||
},
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"enablePremodLinks": "Pre-Moderate Comments Containing Links",
|
||||
"enablePremodLinksDescription": "Moderators must approve any comment containing a link before its published.",
|
||||
"enableQuestionBox": "Ask readers a question",
|
||||
"enableQuestionBoxDescription": "This question will appear at the top of this comment stram. Ask readers about a certain issue in the article or pose discussion questions, etc.",
|
||||
"enableQuestionBoxDescription": "This question will appear at the top of this comment stream. Ask readers about a certain issue in the article or pose discussion questions, etc.",
|
||||
"includeQuestionHere": "Write your question here."
|
||||
}
|
||||
},
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
.Comment {
|
||||
margin-bottom: 15px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.pendingComment {
|
||||
|
||||
@@ -117,7 +117,6 @@ class Comment extends React.Component {
|
||||
const flag = getActionSummary('FlagActionSummary', comment);
|
||||
const dontagree = getActionSummary('DontAgreeActionSummary', comment);
|
||||
let commentClass = parentId ? `reply ${styles.Reply}` : `comment ${styles.Comment}`;
|
||||
commentClass += highlighted === comment.id ? ' highlighted-comment' : '';
|
||||
commentClass += comment.id === 'pending' ? ` ${styles.pendingComment}` : '';
|
||||
|
||||
// call a function, and if it errors, call addNotification('error', ...) (e.g. to show user a snackbar)
|
||||
@@ -147,18 +146,19 @@ class Comment extends React.Component {
|
||||
id={`c_${comment.id}`}
|
||||
style={{marginLeft: depth * 30}}>
|
||||
<hr aria-hidden={true} />
|
||||
<AuthorName
|
||||
author={comment.user}/>
|
||||
{ isStaff(comment.tags)
|
||||
? <TagLabel>Staff</TagLabel>
|
||||
<div className={highlighted === comment.id ? 'highlighted-comment' : ''}>
|
||||
<AuthorName
|
||||
author={comment.user}/>
|
||||
{ isStaff(comment.tags)
|
||||
? <TagLabel>Staff</TagLabel>
|
||||
: null }
|
||||
|
||||
{ commentIsBest(comment)
|
||||
? <TagLabel><BestIndicator /></TagLabel>
|
||||
{ commentIsBest(comment)
|
||||
? <TagLabel><BestIndicator /></TagLabel>
|
||||
: null }
|
||||
<PubDate created_at={comment.created_at} />
|
||||
<PubDate created_at={comment.created_at} />
|
||||
|
||||
<Content body={comment.body} />
|
||||
<Content body={comment.body} />
|
||||
<div className="commentActionsLeft comment__action-container">
|
||||
<ActionButton>
|
||||
<LikeButton
|
||||
@@ -188,21 +188,22 @@ class Comment extends React.Component {
|
||||
</IfUserCanModifyBest>
|
||||
</ActionButton>
|
||||
</div>
|
||||
<div className="commentActionsRight comment__action-container">
|
||||
<ActionButton>
|
||||
<PermalinkButton articleURL={asset.url} commentId={comment.id} />
|
||||
</ActionButton>
|
||||
<ActionButton>
|
||||
<FlagComment
|
||||
flag={flag && flag.current_user ? flag : dontagree}
|
||||
id={comment.id}
|
||||
author_id={comment.user.id}
|
||||
postFlag={postFlag}
|
||||
postDontAgree={postDontAgree}
|
||||
deleteAction={deleteAction}
|
||||
showSignInDialog={showSignInDialog}
|
||||
currentUser={currentUser} />
|
||||
</ActionButton>
|
||||
<div className="commentActionsRight comment__action-container">
|
||||
<ActionButton>
|
||||
<PermalinkButton articleURL={asset.url} commentId={comment.id} />
|
||||
</ActionButton>
|
||||
<ActionButton>
|
||||
<FlagComment
|
||||
flag={flag && flag.current_user ? flag : dontagree}
|
||||
id={comment.id}
|
||||
author_id={comment.user.id}
|
||||
postFlag={postFlag}
|
||||
postDontAgree={postDontAgree}
|
||||
deleteAction={deleteAction}
|
||||
showSignInDialog={showSignInDialog}
|
||||
currentUser={currentUser} />
|
||||
</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
{
|
||||
activeReplyBox === comment.id
|
||||
|
||||
@@ -6,16 +6,17 @@ import I18n from 'coral-framework/modules/i18n/i18n';
|
||||
import translations from 'coral-framework/translations';
|
||||
const lang = new I18n(translations);
|
||||
|
||||
import {TabBar, Tab, TabContent, Spinner} from 'coral-ui';
|
||||
import {TabBar, Tab, TabContent, Spinner, Button} from 'coral-ui';
|
||||
|
||||
const {logout, showSignInDialog, requestConfirmEmail} = authActions;
|
||||
const {addNotification, clearNotification} = notificationActions;
|
||||
const {fetchAssetSuccess} = assetActions;
|
||||
import {NEW_COMMENT_COUNT_POLL_INTERVAL} from 'coral-framework/constants/comments';
|
||||
|
||||
import {queryStream} from 'coral-framework/graphql/queries';
|
||||
import {postComment, postFlag, postLike, postDontAgree, deleteAction, addCommentTag, removeCommentTag} from 'coral-framework/graphql/mutations';
|
||||
import {editName} from 'coral-framework/actions/user';
|
||||
import {updateCountCache} from 'coral-framework/actions/asset';
|
||||
import {updateCountCache, viewAllComments} from 'coral-framework/actions/asset';
|
||||
import {notificationActions, authActions, assetActions, pym} from 'coral-framework';
|
||||
|
||||
import Stream from './Stream';
|
||||
@@ -31,7 +32,7 @@ import ChangeUsernameContainer from '../../coral-sign-in/containers/ChangeUserna
|
||||
import ProfileContainer from 'coral-settings/containers/ProfileContainer';
|
||||
import RestrictedContent from 'coral-framework/components/RestrictedContent';
|
||||
import ConfigureStreamContainer from 'coral-configure/containers/ConfigureStreamContainer';
|
||||
import Comment from './Comment';
|
||||
import HighlightedComment from './Comment';
|
||||
import LoadMore from './LoadMore';
|
||||
import NewCount from './NewCount';
|
||||
|
||||
@@ -68,10 +69,30 @@ class Embed extends Component {
|
||||
pym.sendMessage('childReady');
|
||||
}
|
||||
|
||||
componentWillUnmount () {
|
||||
clearInterval(this.state.countPoll);
|
||||
}
|
||||
|
||||
componentWillReceiveProps (nextProps) {
|
||||
const {loadAsset} = this.props;
|
||||
if(!isEqual(nextProps.data.asset, this.props.data.asset)) {
|
||||
loadAsset(nextProps.data.asset);
|
||||
|
||||
const {getCounts, updateCountCache} = this.props;
|
||||
const {asset} = nextProps.data;
|
||||
|
||||
updateCountCache(asset.id, asset.commentCount);
|
||||
|
||||
this.setState({
|
||||
countPoll: setInterval(() => {
|
||||
const {asset} = this.props.data;
|
||||
getCounts({
|
||||
asset_id: asset.id,
|
||||
limit: asset.comments.length,
|
||||
sort: 'REVERSE_CHRONOLOGICAL'
|
||||
});
|
||||
}, NEW_COMMENT_COUNT_POLL_INTERVAL)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,7 +100,7 @@ class Embed extends Component {
|
||||
if(!isEqual(prevProps.data.comment, this.props.data.comment)) {
|
||||
|
||||
// Scroll to a permalinked comment if one is in the URL once the page is done rendering.
|
||||
setTimeout(()=>pym.scrollParentToChildEl(`c_${this.props.data.comment.id}`), 0);
|
||||
setTimeout(() => pym.scrollParentToChildEl('coralStream'), 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,6 +118,8 @@ class Embed extends Component {
|
||||
const {closedAt, countCache = {}} = this.props.asset;
|
||||
const {loading, asset, refetch, comment} = this.props.data;
|
||||
const {loggedIn, isAdmin, user, showSignInDialog, signInOffset} = this.props.auth;
|
||||
|
||||
// even though the permalinked comment is the highlighted one, we're displaying its parent + replies
|
||||
const highlightedComment = comment && comment.parent ? comment.parent : comment;
|
||||
|
||||
const openStream = closedAt === null;
|
||||
@@ -120,10 +143,20 @@ class Embed extends Component {
|
||||
<div style={expandForLogin}>
|
||||
<div className="commentStream">
|
||||
<TabBar onChange={this.changeTab} activeTab={activeTab}>
|
||||
<Tab><Count count={asset.commentCount}/></Tab>
|
||||
<Tab><Count count={asset.totalCommentCount}/></Tab>
|
||||
<Tab>{lang.t('MY_COMMENTS')}</Tab>
|
||||
<Tab restricted={!isAdmin}>Configure Stream</Tab>
|
||||
</TabBar>
|
||||
{
|
||||
highlightedComment &&
|
||||
<Button
|
||||
cStyle='darkGrey'
|
||||
style={{float: 'right'}}
|
||||
onClick={() => {
|
||||
this.props.viewAllComments();
|
||||
this.props.data.refetch();
|
||||
}}>{lang.t('showAllComments')}</Button>
|
||||
}
|
||||
{loggedIn && <UserBox user={user} logout={() => this.props.logout().then(refetch)} changeTab={this.changeTab}/>}
|
||||
<TabContent show={activeTab === 0}>
|
||||
{
|
||||
@@ -170,9 +203,11 @@ class Embed extends Component {
|
||||
offset={signInOffset}/>}
|
||||
{loggedIn && user && <ChangeUsernameContainer loggedIn={loggedIn} offset={signInOffset} user={user} />}
|
||||
{loggedIn && <ModerationLink assetId={asset.id} isAdmin={isAdmin} />}
|
||||
|
||||
{/* the highlightedComment is isolated after the user followed a permalink */}
|
||||
{
|
||||
highlightedComment &&
|
||||
<Comment
|
||||
highlightedComment
|
||||
? <HighlightedComment
|
||||
refetch={refetch}
|
||||
setActiveReplyBox={this.setActiveReplyBox}
|
||||
activeReplyBox={this.state.activeReplyBox}
|
||||
@@ -191,42 +226,42 @@ class Embed extends Component {
|
||||
key={highlightedComment.id}
|
||||
reactKey={highlightedComment.id}
|
||||
comment={highlightedComment} />
|
||||
: <div>
|
||||
<NewCount
|
||||
commentCount={asset.commentCount}
|
||||
countCache={countCache[asset.id]}
|
||||
loadMore={this.props.loadMore}
|
||||
firstCommentDate={firstCommentDate}
|
||||
assetId={asset.id}
|
||||
updateCountCache={this.props.updateCountCache}
|
||||
/>
|
||||
<div className="embed__stream">
|
||||
<Stream
|
||||
open={openStream}
|
||||
addNotification={this.props.addNotification}
|
||||
postItem={this.props.postItem}
|
||||
setActiveReplyBox={this.setActiveReplyBox}
|
||||
activeReplyBox={this.state.activeReplyBox}
|
||||
asset={asset}
|
||||
currentUser={user}
|
||||
postLike={this.props.postLike}
|
||||
postFlag={this.props.postFlag}
|
||||
postDontAgree={this.props.postDontAgree}
|
||||
addCommentTag={this.props.addCommentTag}
|
||||
removeCommentTag={this.props.removeCommentTag}
|
||||
loadMore={this.props.loadMore}
|
||||
deleteAction={this.props.deleteAction}
|
||||
showSignInDialog={this.props.showSignInDialog}
|
||||
comments={asset.comments} />
|
||||
</div>
|
||||
<LoadMore
|
||||
topLevel={true}
|
||||
assetId={asset.id}
|
||||
comments={asset.comments}
|
||||
moreComments={countCache[asset.id] > asset.comments.length}
|
||||
loadMore={this.props.loadMore} />
|
||||
</div>
|
||||
}
|
||||
<NewCount
|
||||
commentCount={asset.commentCount}
|
||||
countCache={countCache[asset.id]}
|
||||
loadMore={this.props.loadMore}
|
||||
firstCommentDate={firstCommentDate}
|
||||
assetId={asset.id}
|
||||
updateCountCache={this.props.updateCountCache}
|
||||
/>
|
||||
<div className="embed__stream">
|
||||
<Stream
|
||||
open={openStream}
|
||||
addNotification={this.props.addNotification}
|
||||
postItem={this.props.postItem}
|
||||
setActiveReplyBox={this.setActiveReplyBox}
|
||||
activeReplyBox={this.state.activeReplyBox}
|
||||
asset={asset}
|
||||
currentUser={user}
|
||||
postLike={this.props.postLike}
|
||||
postFlag={this.props.postFlag}
|
||||
postDontAgree={this.props.postDontAgree}
|
||||
getCounts={this.props.getCounts}
|
||||
addCommentTag={this.props.addCommentTag}
|
||||
removeCommentTag={this.props.removeCommentTag}
|
||||
updateCountCache={this.props.updateCountCache}
|
||||
loadMore={this.props.loadMore}
|
||||
deleteAction={this.props.deleteAction}
|
||||
showSignInDialog={this.props.showSignInDialog}
|
||||
comments={asset.comments} />
|
||||
</div>
|
||||
<LoadMore
|
||||
topLevel={true}
|
||||
assetId={asset.id}
|
||||
comments={asset.comments}
|
||||
moreComments={countCache[asset.id] > asset.comments.length}
|
||||
loadMore={this.props.loadMore}/>
|
||||
</TabContent>
|
||||
<TabContent show={activeTab === 1}>
|
||||
<ProfileContainer
|
||||
@@ -263,6 +298,7 @@ const mapDispatchToProps = dispatch => ({
|
||||
editName: (username) => dispatch(editName(username)),
|
||||
showSignInDialog: (offset) => dispatch(showSignInDialog(offset)),
|
||||
updateCountCache: (id, count) => dispatch(updateCountCache(id, count)),
|
||||
viewAllComments: () => dispatch(viewAllComments()),
|
||||
logout: () => dispatch(logout()),
|
||||
dispatch: d => dispatch(d)
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@ import {ADDTL_COMMENTS_ON_LOAD_MORE} from 'coral-framework/constants/comments';
|
||||
import {Button} from 'coral-ui';
|
||||
const lang = new I18n(translations);
|
||||
|
||||
const loadMoreComments = (assetId, comments, loadMore, parentId) => {
|
||||
const loadMoreComments = (assetId, comments, loadMore, parentId, replyCount) => {
|
||||
|
||||
let cursor = null;
|
||||
if (comments.length) {
|
||||
@@ -15,7 +15,7 @@ const loadMoreComments = (assetId, comments, loadMore, parentId) => {
|
||||
}
|
||||
|
||||
loadMore({
|
||||
limit: ADDTL_COMMENTS_ON_LOAD_MORE,
|
||||
limit: parentId ? replyCount : ADDTL_COMMENTS_ON_LOAD_MORE,
|
||||
cursor,
|
||||
asset_id: assetId,
|
||||
parent_id: parentId,
|
||||
@@ -48,7 +48,7 @@ class LoadMore extends React.Component {
|
||||
<Button
|
||||
onClick={() => {
|
||||
this.initialState = false;
|
||||
loadMoreComments(assetId, comments, loadMore, parentId);
|
||||
loadMoreComments(assetId, comments, loadMore, parentId, replyCount);
|
||||
}}>
|
||||
{topLevel ? lang.t('viewMoreComments') : this.replyCountFormat(replyCount)}
|
||||
</Button>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React, {PropTypes} from 'react';
|
||||
import Comment from './Comment';
|
||||
import {NEW_COMMENT_COUNT_POLL_INTERVAL} from 'coral-framework/constants/comments';
|
||||
|
||||
class Stream extends React.Component {
|
||||
|
||||
@@ -27,26 +26,6 @@ class Stream extends React.Component {
|
||||
this.state = {activeReplyBox: '', countPoll: null};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const {asset, getCounts, updateCountCache} = this.props;
|
||||
|
||||
updateCountCache(asset.id, asset.commentCount);
|
||||
|
||||
// Note: Apollo's built-in polling doesn't work with fetchMore queries, so a
|
||||
// setInterval is being used instead.
|
||||
this.setState({
|
||||
countPoll: setInterval(() => getCounts({
|
||||
asset_id: asset.id,
|
||||
limit: asset.comments.length,
|
||||
sort: 'REVERSE_CHRONOLOGICAL'
|
||||
}), NEW_COMMENT_COUNT_POLL_INTERVAL),
|
||||
});
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
clearInterval(this.state.countPoll);
|
||||
}
|
||||
|
||||
render () {
|
||||
const {
|
||||
comments,
|
||||
|
||||
@@ -16,6 +16,7 @@ body {
|
||||
font-size: 14px;
|
||||
margin: 0px;
|
||||
padding: 0px 0px 50px 0px;
|
||||
height: auto !important;
|
||||
}
|
||||
|
||||
.expandForSignin {
|
||||
@@ -306,10 +307,7 @@ button.comment__action-button[disabled],
|
||||
display: none;
|
||||
background-color: white;
|
||||
border: 1px solid black;
|
||||
width: calc(100% - 15px);
|
||||
position: absolute;
|
||||
top: 70px;
|
||||
right: 0;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
|
||||
@@ -74,6 +74,15 @@ function configurePymParent(pymParent, asset_url) {
|
||||
snackbar.style.opacity = 0;
|
||||
});
|
||||
|
||||
// remove the permalink comment id from the hash
|
||||
pymParent.onMessage('coral-view-all-comments', function () {
|
||||
window.history.replaceState(
|
||||
{},
|
||||
document.title,
|
||||
location.origin + location.pathname + location.search
|
||||
);
|
||||
});
|
||||
|
||||
pymParent.onMessage('coral-alert', function (message) {
|
||||
const [type, text] = message.split('|');
|
||||
snackbar.style.transform = 'translate(-50%, 20px)';
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as actions from '../constants/asset';
|
||||
import coralApi from '../helpers/response';
|
||||
import {addNotification} from '../actions/notification';
|
||||
import {pym} from 'coral-framework';
|
||||
|
||||
import I18n from '../../coral-framework/modules/i18n/i18n';
|
||||
import translations from './../translations';
|
||||
@@ -49,3 +50,37 @@ export const updateOpenStatus = status => dispatch => {
|
||||
dispatch(updateOpenStream({closedAt: new Date().getTime()}));
|
||||
}
|
||||
};
|
||||
|
||||
function removeParam(key, sourceURL) {
|
||||
let rtn = sourceURL.split('?')[0];
|
||||
let param;
|
||||
let params_arr = [];
|
||||
let queryString = (sourceURL.indexOf('?') !== -1) ? sourceURL.split('?')[1] : '';
|
||||
if (queryString !== '') {
|
||||
params_arr = queryString.split('&');
|
||||
for (let i = params_arr.length - 1; i >= 0; i -= 1) {
|
||||
param = params_arr[i].split('=')[0];
|
||||
if (param === key) {
|
||||
params_arr.splice(i, 1);
|
||||
}
|
||||
}
|
||||
rtn = `${rtn}?${params_arr.join('&')}`;
|
||||
}
|
||||
return rtn;
|
||||
}
|
||||
|
||||
export const viewAllComments = () => {
|
||||
|
||||
// remove the comment_id url param
|
||||
const modifiedUrl = removeParam('comment_id', location.href);
|
||||
try {
|
||||
|
||||
// "window" here refers to the embedded iframe
|
||||
window.history.replaceState({}, document.title, modifiedUrl);
|
||||
|
||||
// also change the parent url
|
||||
pym.sendMessage('coral-view-all-comments');
|
||||
} catch (e) { /* not sure if we're worried about old browsers */ }
|
||||
|
||||
return {type: actions.VIEW_ALL_COMMENTS};
|
||||
};
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
import {addNotification} from '../actions/notification';
|
||||
import coralApi from '../helpers/response';
|
||||
import * as actions from '../constants/auth';
|
||||
|
||||
import I18n from 'coral-framework/modules/i18n/i18n';
|
||||
import translations from './../translations';
|
||||
const lang = new I18n(translations);
|
||||
|
||||
const editUsernameFailure = error => ({type: actions.EDIT_USERNAME_FAILURE, error});
|
||||
const editUsernameSuccess = () => ({type: actions.EDIT_USERNAME_SUCCESS});
|
||||
|
||||
export const editName = (username) => (dispatch) => {
|
||||
return coralApi('/account/username', {method: 'PUT', body: {username}})
|
||||
.then(() => {
|
||||
dispatch(editUsernameSuccess());
|
||||
dispatch(addNotification('success', lang.t('successNameUpdate')));
|
||||
})
|
||||
.catch(error => {
|
||||
dispatch(editUsernameFailure(lang.t(`error.${error.translation_key}`)));
|
||||
});
|
||||
};
|
||||
|
||||
@@ -9,3 +9,5 @@ export const UPDATE_ASSET_SETTINGS_FAILURE = 'UPDATE_ASSET_SETTINGS_FAILURE';
|
||||
export const OPEN_COMMENTS = 'OPEN_COMMENTS';
|
||||
export const CLOSE_COMMENTS = 'CLOSE_COMMENTS';
|
||||
export const UPDATE_COUNT_CACHE = 'UPDATE_COUNT_CACHE';
|
||||
|
||||
export const VIEW_ALL_COMMENTS = 'VIEW_ALL_COMMENTS';
|
||||
|
||||
@@ -11,6 +11,11 @@ export const CREATE_USERNAME = 'CREATE_USERNAME';
|
||||
export const SHOW_CREATEUSERNAME_DIALOG = 'SHOW_CREATEUSERNAME_DIALOG';
|
||||
export const HIDE_CREATEUSERNAME_DIALOG = 'HIDE_CREATEUSERNAME_DIALOG';
|
||||
|
||||
export const EDIT_USERNAME_REQUEST = 'CREATE_USERNAME_REQUEST';
|
||||
export const EDIT_USERNAME_SUCCESS = 'CREATE_USERNAME_SUCCESS';
|
||||
export const EDIT_USERNAME_FAILURE = 'CREATE_USERNAME_FAILURE';
|
||||
export const EDIT_USERNAME = 'CREATE_USERNAME';
|
||||
|
||||
export const FETCH_SIGNUP_REQUEST = 'FETCH_SIGNUP_REQUEST';
|
||||
export const FETCH_SIGNUP_FAILURE = 'FETCH_SIGNUP_FAILURE';
|
||||
export const FETCH_SIGNUP_SUCCESS = 'FETCH_SIGNUP_SUCCESS';
|
||||
|
||||
@@ -5,6 +5,7 @@ import GET_COUNTS from './getCounts.graphql';
|
||||
import MY_COMMENT_HISTORY from './myCommentHistory.graphql';
|
||||
import uniqBy from 'lodash/uniqBy';
|
||||
import sortBy from 'lodash/sortBy';
|
||||
import isNil from 'lodash/isNil';
|
||||
|
||||
function getQueryVariable(variable) {
|
||||
let query = window.location.search.substring(1);
|
||||
@@ -20,6 +21,7 @@ function getQueryVariable(variable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// get the counts of the top-level comments
|
||||
export const getCounts = (data) => ({asset_id, limit, sort}) => {
|
||||
return data.fetchMore({
|
||||
query: GET_COUNTS,
|
||||
@@ -41,23 +43,48 @@ export const getCounts = (data) => ({asset_id, limit, sort}) => {
|
||||
});
|
||||
};
|
||||
|
||||
// handle paginated requests for more Comments pertaining to the Asset
|
||||
export const loadMore = (data) => ({limit, cursor, parent_id = null, asset_id, sort}, newComments) => {
|
||||
return data.fetchMore({
|
||||
query: LOAD_MORE,
|
||||
variables: {
|
||||
limit,
|
||||
cursor,
|
||||
parent_id,
|
||||
asset_id,
|
||||
sort
|
||||
limit, // how many comments are we returning
|
||||
cursor, // the date of the first/last comment depending on the sort order
|
||||
parent_id, // if null, we're loading more top-level comments, if not, we're loading more replies to a comment
|
||||
asset_id, // the id of the asset we're currently on
|
||||
sort // CHRONOLOGICAL or REVERSE_CHRONOLOGICAL
|
||||
},
|
||||
updateQuery: (oldData, {fetchMoreResult:{data:{new_top_level_comments}}}) => {
|
||||
|
||||
let updatedAsset;
|
||||
|
||||
if (parent_id) {
|
||||
if (!isNil(oldData.comment)) { // loaded replies on a highlighted (permalinked) comment
|
||||
|
||||
let comment = {};
|
||||
if (oldData.comment && oldData.comment.parent) {
|
||||
|
||||
// put comments (replies) onto the oldData.comment.parent object
|
||||
// the initial comment permalinked was a reply
|
||||
const uniqReplies = uniqBy([...new_top_level_comments, ...oldData.comment.parent.replies], 'id');
|
||||
comment.parent = {...oldData.comment.parent, replies: sortBy(uniqReplies, 'created_at')};
|
||||
} else if (oldData.comment) {
|
||||
|
||||
// put the comments (replies) directly onto oldData.comment
|
||||
// the initial comment permalinked was a top-level comment
|
||||
const uniqReplies = uniqBy([...new_top_level_comments, ...oldData.comment.replies], 'id');
|
||||
comment.replies = sortBy(uniqReplies, 'created_at');
|
||||
}
|
||||
|
||||
updatedAsset = {
|
||||
...oldData,
|
||||
comment: {
|
||||
...oldData.comment,
|
||||
...comment
|
||||
}
|
||||
};
|
||||
|
||||
} else if (parent_id) { // If loading more replies
|
||||
|
||||
// If loading more replies
|
||||
updatedAsset = {
|
||||
...oldData,
|
||||
asset: {
|
||||
@@ -76,9 +103,8 @@ export const loadMore = (data) => ({limit, cursor, parent_id = null, asset_id, s
|
||||
})
|
||||
}
|
||||
};
|
||||
} else {
|
||||
} else { // If loading more top-level comments
|
||||
|
||||
// If loading more top-level comments
|
||||
updatedAsset = {
|
||||
...oldData,
|
||||
asset: {
|
||||
@@ -94,8 +120,11 @@ export const loadMore = (data) => ({limit, cursor, parent_id = null, asset_id, s
|
||||
});
|
||||
};
|
||||
|
||||
// load the comment stream.
|
||||
export const queryStream = graphql(STREAM_QUERY, {
|
||||
options: () => {
|
||||
|
||||
// where the query string is from the embeded iframe url
|
||||
let comment_id = getQueryVariable('comment_id');
|
||||
let has_comment = comment_id != null;
|
||||
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
#import "../fragments/commentView.graphql"
|
||||
|
||||
query AssetQuery($asset_id: ID, $asset_url: String!, $comment_id: ID!, $has_comment: Boolean!) {
|
||||
# the comment here is for loading one comment and it's children, probably after following a permalink
|
||||
# $has_comment is derived from the comment_id query param in the iframe url,
|
||||
# which is in turn pulled from the host page url
|
||||
comment(id: $comment_id) @include(if: $has_comment) {
|
||||
...commentView
|
||||
replyCount
|
||||
replies {
|
||||
...commentView
|
||||
}
|
||||
parent {
|
||||
...commentView
|
||||
replyCount
|
||||
replies {
|
||||
...commentView
|
||||
}
|
||||
@@ -29,6 +37,7 @@ query AssetQuery($asset_id: ID, $asset_url: String!, $comment_id: ID!, $has_comm
|
||||
requireEmailConfirmation
|
||||
}
|
||||
commentCount
|
||||
totalCommentCount
|
||||
comments(limit: 10) {
|
||||
...commentView
|
||||
replyCount
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"error": "Usernames can contain letters, numbers and _ only"
|
||||
},
|
||||
"viewMoreComments": "view more comments",
|
||||
"showAllComments": "Show all comments",
|
||||
"viewReply": "view reply",
|
||||
"viewAllRepliesInitial": "view all {0} replies",
|
||||
"viewAllReplies": "view {0} replies",
|
||||
@@ -56,6 +57,7 @@
|
||||
"newCount": "Ver {0} {1} más",
|
||||
"comment": "commentario",
|
||||
"comments": "commentarios",
|
||||
"showAllComments": "Mostrar todos los comentarios",
|
||||
"error": {
|
||||
"emailNotVerified": "E-mail {0} no verificado.",
|
||||
"email": "No es un e-mail válido",
|
||||
|
||||
@@ -23,14 +23,14 @@ const getPopupMenu = [
|
||||
{val: 'This comment is offensive', text: lang.t('comment-offensive')},
|
||||
{val: 'This looks like an ad/marketing', text: lang.t('marketing')},
|
||||
{val: 'I don\'t agree with this comment', text: lang.t('no-agree-comment')},
|
||||
{val: 'other', text: lang.t('other')}
|
||||
{val: 'Other', text: lang.t('other')}
|
||||
]
|
||||
: [
|
||||
{val: 'This username is offensive', text: lang.t('username-offensive')},
|
||||
{val: 'I don\'t like this username', text: lang.t('no-like-username')},
|
||||
{val: 'This user is impersonating', text: lang.t('user-impersonating')},
|
||||
{val: 'This looks like an ad/marketing', text: lang.t('marketing')},
|
||||
{val: 'other', text: lang.t('other')}
|
||||
{val: 'Other', text: lang.t('other')}
|
||||
];
|
||||
return {
|
||||
header: lang.t('step-2-header'),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { PropTypes } from 'react';
|
||||
import { Icon } from '../coral-ui';
|
||||
import React, {PropTypes} from 'react';
|
||||
import {Icon} from '../coral-ui';
|
||||
import styles from './Comment.css';
|
||||
import PubDate from '../coral-plugin-pubdate/PubDate';
|
||||
import Content from '../coral-plugin-commentcontent/CommentContent';
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import React from 'react';
|
||||
import Markdown from './Markdown';
|
||||
|
||||
const packagename = 'coral-plugin-infobox';
|
||||
|
||||
const InfoBox = ({enable, content}) =>
|
||||
<div
|
||||
className={`${packagename}-info ${enable ? null : ', hidden'}` }>
|
||||
{content}
|
||||
className={`${packagename}-info ${enable ? '' : 'hidden'}` }>
|
||||
<Markdown content={content} />
|
||||
</div>;
|
||||
|
||||
export default InfoBox;
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import React, {PureComponent, PropTypes} from 'react';
|
||||
import marked from 'marked';
|
||||
|
||||
const renderer = new marked.Renderer();
|
||||
|
||||
// Set link target to `_parent` to work properly in an embed.
|
||||
renderer.link = ( href, title, text ) =>
|
||||
`<a target="_parent" href="${href}" title="${title}">${text}</a>`;
|
||||
|
||||
marked.setOptions({renderer});
|
||||
|
||||
export default class Markdown extends PureComponent {
|
||||
render() {
|
||||
const {content, ...rest} = this.props;
|
||||
const __html = marked(content);
|
||||
return <div {...rest} dangerouslySetInnerHTML={{__html}} />;
|
||||
}
|
||||
}
|
||||
|
||||
Markdown.propTypes = {
|
||||
content: PropTypes.string,
|
||||
};
|
||||
|
||||
@@ -3,24 +3,27 @@ import {shallow} from 'enzyme';
|
||||
import {expect} from 'chai';
|
||||
import InfoBox from '../InfoBox';
|
||||
|
||||
const render = props => shallow(<InfoBox {...props} />);
|
||||
|
||||
describe('InfoBox', () => {
|
||||
let comment;
|
||||
let render;
|
||||
beforeEach(() => {
|
||||
comment = {};
|
||||
const postItem = (item) => {
|
||||
comment.posted = item;
|
||||
return Promise.resolve(4);
|
||||
};
|
||||
render = shallow(<InfoBox
|
||||
postItem={postItem}
|
||||
updateItem={(e) => comment.text = e.target.value}
|
||||
item_id={'1'}
|
||||
comments={['1', '2', '3']}/>);
|
||||
it('should render hidden InfoBox', () => {
|
||||
const wrapper = render();
|
||||
const className = wrapper.prop('className');
|
||||
expect(className).to.include('-info');
|
||||
expect(className).to.include('hidden');
|
||||
});
|
||||
|
||||
it('should render the InfoBox appropriately', () => {
|
||||
expect(render.contains('<div class="InfoBox"')).to.be.truthy;
|
||||
expect(render.contains('<button class="postCommentButton"')).to.be.truthy;
|
||||
it('should render enabled InfoBox', () => {
|
||||
const wrapper = render({enable: true});
|
||||
const className = wrapper.prop('className');
|
||||
expect(className).to.include('-info');
|
||||
expect(className).to.not.include('hidden');
|
||||
});
|
||||
|
||||
it('should render Markdown', () => {
|
||||
const wrapper = render({content: 'x'});
|
||||
const Markddown = wrapper.find('Markdown');
|
||||
expect(Markddown).to.have.length(1);
|
||||
expect(Markddown.prop('content')).to.equal('x');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import React from 'react';
|
||||
import {shallow} from 'enzyme';
|
||||
import {expect} from 'chai';
|
||||
import Markdown from '../Markdown';
|
||||
|
||||
const render = props => shallow(<Markdown {...props} />);
|
||||
|
||||
describe('Markdown', () => {
|
||||
it('should convert Markdown to html', () => {
|
||||
const wrapper = render({content: '*test*'});
|
||||
const html = wrapper.html();
|
||||
expect(html).to.contain('<em>');
|
||||
});
|
||||
|
||||
it('should set target="_parent" for links', () => {
|
||||
const wrapper = render({content: '[link](https://coralproject.net)'});
|
||||
const html = wrapper.html();
|
||||
expect(html).to.contain('target="_parent"');
|
||||
});
|
||||
});
|
||||
@@ -23,6 +23,10 @@ class PermalinkButton extends React.Component {
|
||||
}
|
||||
|
||||
toggle () {
|
||||
|
||||
// I wish I could position this with a stylesheet, but top-level comments with
|
||||
// nested replies throws everything off, as well as very long comments
|
||||
this.popover.style.top = `${this.linkButton.offsetTop - 80}px`;
|
||||
this.setState({popoverOpen: !this.state.popoverOpen});
|
||||
}
|
||||
|
||||
@@ -48,11 +52,16 @@ class PermalinkButton extends React.Component {
|
||||
const {copySuccessful, copyFailure} = this.state;
|
||||
return (
|
||||
<div className={`${name}-container`}>
|
||||
<button onClick={this.toggle} className={`${name}-button`}>
|
||||
<button
|
||||
ref={ref => this.linkButton = ref}
|
||||
onClick={this.toggle}
|
||||
className={`${name}-button`}>
|
||||
{lang.t('permalink.permalink')}
|
||||
<i className={`${name}-icon material-icons`} aria-hidden={true}>link</i>
|
||||
</button>
|
||||
<div className={`${name}-popover ${styles.container} ${this.state.popoverOpen ? 'active' : ''}`}>
|
||||
<div
|
||||
ref={ref => this.popover = ref}
|
||||
className={`${name}-popover ${styles.container} ${this.state.popoverOpen ? 'active' : ''}`}>
|
||||
<input
|
||||
className={`${name}-copy-field`}
|
||||
type='text'
|
||||
|
||||
@@ -4,22 +4,15 @@
|
||||
box-sizing: border-box;
|
||||
/* box-shadow: 3px 3px 5px 0 rgba(0, 0, 0, 0.3); */
|
||||
border: solid 1px rgba(153, 153, 153, 0.33);
|
||||
width: auto;
|
||||
margin: 0 auto;
|
||||
left: 0;
|
||||
margin: 0 5px;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
margin-top: -13px;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
border: 10px solid transparent;
|
||||
border-top-color: white;
|
||||
position: absolute;
|
||||
right: 8.69em;
|
||||
right: 7em;
|
||||
bottom: -20px;
|
||||
z-index: 2;
|
||||
}
|
||||
@@ -29,7 +22,7 @@
|
||||
border: 10px solid transparent;
|
||||
border-top-color: rgba(153, 153, 153, 0.33);
|
||||
position: absolute;
|
||||
right: 8.69em;
|
||||
right: 7em;
|
||||
bottom: -21px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
@@ -10,13 +10,13 @@
|
||||
"fromSettingsPage": "From the Profile Page you can see your comment history."
|
||||
},
|
||||
"es":{
|
||||
"profile": "Pérfil",
|
||||
"userNoComment": "No has dejado áun ningún comentario. ¡Unete a la conversación!",
|
||||
"profile": "Perfil",
|
||||
"userNoComment": "No has dejado aún ningún comentario. ¡Únete a la conversación!",
|
||||
"allComments": "Todos los comentarios",
|
||||
"profileSettings": "Configuración del pérfil",
|
||||
"profileSettings": "Configuración del perfil",
|
||||
"myCommentHistory": "Mi historial de comentarios",
|
||||
"signIn": "Entrar",
|
||||
"toAccess": "para acceder a al pérfil",
|
||||
"fromSettingsPage": "Desde la peagina de configuración puedes ver tu historia de comentarios."
|
||||
"toAccess": "para acceder a al perfil",
|
||||
"fromSettingsPage": "Desde la página de configuración puedes ver tu historia de comentarios."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
min-height: 200px;
|
||||
overflow: hidden;
|
||||
width: 330px;
|
||||
z-index: 1;
|
||||
position: relative;
|
||||
background: #fff;
|
||||
border-radius: 2px;
|
||||
|
||||
@@ -3,11 +3,51 @@ const DataLoader = require('dataloader');
|
||||
const util = require('./util');
|
||||
|
||||
const UsersService = require('../../services/users');
|
||||
const UserModel = require('../../models/user');
|
||||
|
||||
const genUserByIDs = (context, ids) => UsersService
|
||||
.findByIdArray(ids)
|
||||
.then(util.singleJoinBy(ids, 'id'));
|
||||
|
||||
/**
|
||||
* Retrieves users based on the passed in query that is filtered by the
|
||||
* current used passed in via the context.
|
||||
* @param {Object} context graph context
|
||||
* @param {Object} query query terms to apply to the users query
|
||||
*/
|
||||
const getUsersByQuery = ({user}, {ids, limit, cursor, sort}) => {
|
||||
|
||||
let users = UserModel.find();
|
||||
|
||||
if (ids) {
|
||||
users = users.find({
|
||||
id: {
|
||||
$in: ids
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (cursor) {
|
||||
if (sort === 'REVERSE_CHRONOLOGICAL') {
|
||||
users = users.where({
|
||||
created_at: {
|
||||
$lt: cursor
|
||||
}
|
||||
});
|
||||
} else {
|
||||
users = users.where({
|
||||
created_at: {
|
||||
$gt: cursor
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return users
|
||||
.sort({created_at: sort === 'REVERSE_CHRONOLOGICAL' ? -1 : 1})
|
||||
.limit(limit);
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a set of loaders based on a GraphQL context.
|
||||
* @param {Object} context the context of the GraphQL request
|
||||
@@ -15,6 +55,7 @@ const genUserByIDs = (context, ids) => UsersService
|
||||
*/
|
||||
module.exports = (context) => ({
|
||||
Users: {
|
||||
getByQuery: (query) => getUsersByQuery(context, query),
|
||||
getByID: new DataLoader((ids) => genUserByIDs(context, ids))
|
||||
}
|
||||
});
|
||||
|
||||
+20
-10
@@ -6,18 +6,28 @@ const setUserStatus = ({user}, {id, status}) => {
|
||||
.then(res => res);
|
||||
};
|
||||
|
||||
module.exports = (context) => {
|
||||
if (context.user && context.user.can('mutation:setUserStatus')) {
|
||||
return {
|
||||
User: {
|
||||
setUserStatus: (action) => setUserStatus(context, action)
|
||||
}
|
||||
};
|
||||
}
|
||||
const suspendUser = ({user}, {id, message}) => {
|
||||
return UsersService.suspendUser(id, message)
|
||||
.then(res => {
|
||||
return res;
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
module.exports = (context) => {
|
||||
let mutators = {
|
||||
User: {
|
||||
setUserStatus: () => Promise.reject(errors.ErrNotAuthorized)
|
||||
setUserStatus: () => Promise.reject(errors.ErrNotAuthorized),
|
||||
suspendUser: () => Promise.reject(errors.ErrNotAuthorized)
|
||||
}
|
||||
};
|
||||
|
||||
if (context.user && context.user.can('mutation:setUserStatus')) {
|
||||
mutators.User.setUserStatus = (action) => setUserStatus(context, action);
|
||||
}
|
||||
|
||||
if (context.user && context.user.can('mutation:suspendUser')) {
|
||||
mutators.User.suspendUser = (action) => suspendUser(context, action);
|
||||
}
|
||||
|
||||
return mutators;
|
||||
};
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
const FlagAction = {
|
||||
|
||||
// Stored in the metadata, extract and return.
|
||||
reason({metadata: {reason}}) {
|
||||
return reason;
|
||||
}
|
||||
message({metadata: {message}}) {
|
||||
return message;
|
||||
},
|
||||
reason({group_id}) {
|
||||
return group_id;
|
||||
},
|
||||
user({user_id}, _, {loaders: {Users}}) {
|
||||
return Users.getByID.load(user_id);
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = FlagAction;
|
||||
|
||||
@@ -47,6 +47,9 @@ const RootMutation = {
|
||||
setUserStatus(_, {id, status}, {mutators: {User}}) {
|
||||
return wrapResponse(null)(User.setUserStatus({id, status}));
|
||||
},
|
||||
suspendUser(_, {id, message}, {mutators: {User}}) {
|
||||
return wrapResponse(null)(User.suspendUser({id, message}));
|
||||
},
|
||||
setCommentStatus(_, {id, status}, {mutators: {Comment}}) {
|
||||
return wrapResponse(null)(Comment.setCommentStatus({id, status}));
|
||||
},
|
||||
|
||||
@@ -86,6 +86,28 @@ 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.
|
||||
users(_, {query: {action_type, limit, cursor, sort}}, {user, loaders: {Users, Actions}}) {
|
||||
|
||||
if (user == null || !user.hasRoles('ADMIN')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const query = {limit, cursor, sort};
|
||||
|
||||
if (action_type) {
|
||||
return Actions.getByTypes({action_type, item_type: 'USERS'})
|
||||
.then((ids) => {
|
||||
|
||||
// Perform the query using the available resolver.
|
||||
return Users.getByQuery({ids, limit, cursor, sort}).find({status: 'PENDING'});
|
||||
});
|
||||
}
|
||||
|
||||
return Users.getByQuery(query);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -45,6 +45,9 @@ type User {
|
||||
# returns all comments based on a query.
|
||||
comments(query: CommentsQuery): [Comment]
|
||||
|
||||
# returns all users based on a query.
|
||||
users(query: UsersQuery): [User]
|
||||
|
||||
# returns user status
|
||||
status: USER_STATUS
|
||||
}
|
||||
@@ -60,6 +63,20 @@ type Tag {
|
||||
created_at: Date!
|
||||
}
|
||||
|
||||
# UsersQuery allows the ability to query users by a specific fields.
|
||||
input UsersQuery {
|
||||
action_type: ACTION_TYPE
|
||||
|
||||
# Limit the number of results to be returned.
|
||||
limit: Int = 10
|
||||
|
||||
# Skip results from the last created_at timestamp.
|
||||
cursor: Date
|
||||
|
||||
# Sort the results by created_at.
|
||||
sort: SORT_ORDER = REVERSE_CHRONOLOGICAL
|
||||
}
|
||||
|
||||
################################################################################
|
||||
## Comments
|
||||
################################################################################
|
||||
@@ -520,6 +537,9 @@ type RootQuery {
|
||||
# role.
|
||||
me: User
|
||||
|
||||
# Users returned based on a query.
|
||||
users(query: UsersQuery): [User]
|
||||
|
||||
# Asset metrics related to user actions are saturated into the assets
|
||||
# returned.
|
||||
assetMetrics(from: Date!, to: Date!, sort: ASSET_METRICS_SORT!, limit: Int = 10): [Asset!]
|
||||
@@ -653,6 +673,14 @@ type SetUserStatusResponse implements Response {
|
||||
errors: [UserError]
|
||||
}
|
||||
|
||||
# SuspendUserResponse is the response returned with possibly some errors
|
||||
# relating to the suspend action attempt.
|
||||
type SuspendUserResponse implements Response {
|
||||
|
||||
# An array of errors relating to the mutation that occurred.
|
||||
errors: [UserError]
|
||||
}
|
||||
|
||||
# SetCommentStatusResponse is the response returned with possibly some errors
|
||||
# relating to the delete action attempt.
|
||||
type SetCommentStatusResponse implements Response {
|
||||
@@ -696,6 +724,9 @@ type RootMutation {
|
||||
# Sets User status. Requires the `ADMIN` role.
|
||||
setUserStatus(id: ID!, status: USER_STATUS!): SetUserStatusResponse
|
||||
|
||||
# Sets User status to BANNED and canEditName to true. It sends a message to the banned User. Requires the `ADMIN` role.
|
||||
suspendUser(id: ID!, message: String): SuspendUserResponse
|
||||
|
||||
# Sets Comment status. Requires the `ADMIN` role.
|
||||
setCommentStatus(id: ID!, status: COMMENT_STATUS!): SetCommentStatusResponse
|
||||
|
||||
|
||||
@@ -47,6 +47,10 @@ const SettingSchema = new Schema({
|
||||
organizationName: {
|
||||
type: String
|
||||
},
|
||||
autoCloseStream: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
closedTimeout: {
|
||||
type: Number,
|
||||
|
||||
|
||||
+3
-2
@@ -176,9 +176,10 @@ const USER_GRAPH_OPERATIONS = [
|
||||
'mutation:deleteAction',
|
||||
'mutation:editName',
|
||||
'mutation:setUserStatus',
|
||||
'mutation:suspendUser',
|
||||
'mutation:setCommentStatus',
|
||||
'mutation:addCommentTag',
|
||||
'mutation:removeCommentTag',
|
||||
'mutation:removeCommentTag'
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -194,7 +195,7 @@ UserSchema.method('can', function(...actions) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (actions.some((action) => action === 'mutation:setUserStatus' || action === 'mutation:setCommentStatus') && !this.hasRoles('ADMIN')) {
|
||||
if (actions.some((action) => action === 'mutation:setUserStatus' || action === 'mutation:suspendUser' || action === 'mutation:setCommentStatus') && !this.hasRoles('ADMIN')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
+34
-32
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "talk",
|
||||
"version": "1.1.0",
|
||||
"version": "1.4.0",
|
||||
"description": "A commenting platform from The Coral Project. https://coralproject.net",
|
||||
"main": "app.js",
|
||||
"scripts": {
|
||||
@@ -50,34 +50,35 @@
|
||||
"homepage": "https://github.com/coralproject/talk#readme",
|
||||
"dependencies": {
|
||||
"bcrypt": "^1.0.2",
|
||||
"body-parser": "^1.15.2",
|
||||
"body-parser": "^1.17.1",
|
||||
"cli-table": "^0.3.1",
|
||||
"commander": "^2.9.0",
|
||||
"connect-redis": "^3.1.0",
|
||||
"csurf": "^1.9.0",
|
||||
"dataloader": "^1.2.0",
|
||||
"debug": "^2.2.0",
|
||||
"dataloader": "^1.3.0",
|
||||
"debug": "^2.6.3",
|
||||
"dotenv": "^4.0.0",
|
||||
"ejs": "^2.5.2",
|
||||
"ejs": "^2.5.6",
|
||||
"env-rewrite": "^1.0.2",
|
||||
"express": "^4.14.0",
|
||||
"express-session": "^1.14.2",
|
||||
"gql-merge": "^0.0.4",
|
||||
"express": "^4.15.2",
|
||||
"express-session": "^1.15.1",
|
||||
"form-data": "^2.1.2",
|
||||
"gql-merge": "^0.0.4",
|
||||
"graphql": "^0.8.2",
|
||||
"graphql-errors": "^2.1.0",
|
||||
"graphql-server-express": "^0.5.0",
|
||||
"graphql-tools": "^0.9.0",
|
||||
"helmet": "^3.1.0",
|
||||
"inquirer": "^3.0.1",
|
||||
"jsonwebtoken": "^7.1.9",
|
||||
"helmet": "^3.5.0",
|
||||
"inquirer": "^3.0.6",
|
||||
"jsonwebtoken": "^7.3.0",
|
||||
"kue": "^0.11.5",
|
||||
"linkify-it": "^2.0.3",
|
||||
"lodash": "^4.16.6",
|
||||
"marked": "^0.3.6",
|
||||
"metascraper": "^1.0.6",
|
||||
"minimist": "^1.2.0",
|
||||
"mongoose": "^4.6.5",
|
||||
"morgan": "^1.7.0",
|
||||
"mongoose": "^4.9.1",
|
||||
"morgan": "^1.8.1",
|
||||
"natural": "^0.4.0",
|
||||
"node-fetch": "^1.6.3",
|
||||
"nodemailer": "^2.6.4",
|
||||
@@ -87,31 +88,32 @@
|
||||
"passport-local": "^1.0.0",
|
||||
"react-apollo": "^0.10.0",
|
||||
"react-recaptcha": "^2.2.6",
|
||||
"redis": "^2.6.5",
|
||||
"redis": "^2.7.1",
|
||||
"simplemde": "^1.11.2",
|
||||
"uuid": "^2.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"apollo-client": "^0.8.3",
|
||||
"autoprefixer": "^6.5.2",
|
||||
"babel-core": "^6.21.0",
|
||||
"babel-eslint": "^7.1.0",
|
||||
"babel-jest": "^15.0.0",
|
||||
"babel-loader": "^6.2.7",
|
||||
"babel-core": "^6.24.0",
|
||||
"babel-eslint": "^7.2.1",
|
||||
"babel-jest": "^19.0.0",
|
||||
"babel-loader": "^6.4.1",
|
||||
"babel-plugin-add-module-exports": "^0.2.1",
|
||||
"babel-plugin-transform-async-to-generator": "^6.16.0",
|
||||
"babel-plugin-transform-class-properties": "^6.18.0",
|
||||
"babel-plugin-transform-class-properties": "^6.23.0",
|
||||
"babel-plugin-transform-decorators-legacy": "^1.3.4",
|
||||
"babel-plugin-transform-object-assign": "^6.8.0",
|
||||
"babel-plugin-transform-object-rest-spread": "^6.16.0",
|
||||
"babel-plugin-transform-react-jsx": "^6.8.0",
|
||||
"babel-polyfill": "^6.16.0",
|
||||
"babel-preset-es2015": "^6.18.0",
|
||||
"babel-plugin-transform-object-rest-spread": "^6.23.0",
|
||||
"babel-plugin-transform-react-jsx": "^6.23.0",
|
||||
"babel-polyfill": "^6.23.0",
|
||||
"babel-preset-es2015": "^6.24.0",
|
||||
"babel-preset-stage-0": "^6.16.0",
|
||||
"chai": "^3.5.0",
|
||||
"chai-as-promised": "^6.0.0",
|
||||
"chai-http": "^3.0.0",
|
||||
"copy-webpack-plugin": "^4.0.0",
|
||||
"css-loader": "^0.25.0",
|
||||
"css-loader": "^0.27.3",
|
||||
"dialog-polyfill": "^0.4.4",
|
||||
"enzyme": "^2.6.0",
|
||||
"eslint": "^3.12.1",
|
||||
@@ -124,14 +126,14 @@
|
||||
"eslint-plugin-promise": "^3.3.1",
|
||||
"eslint-plugin-react": "^6.6.0",
|
||||
"eslint-plugin-standard": "^2.0.1",
|
||||
"exports-loader": "^0.6.3",
|
||||
"exports-loader": "^0.6.4",
|
||||
"fetch-mock": "^5.5.0",
|
||||
"graphql-docs": "^0.2.0",
|
||||
"graphql-tag": "^1.2.3",
|
||||
"hammerjs": "^2.0.8",
|
||||
"ignore-styles": "^5.0.1",
|
||||
"immutable": "^3.8.1",
|
||||
"imports-loader": "^0.6.5",
|
||||
"imports-loader": "^0.7.1",
|
||||
"istanbul": "^1.1.0-alpha.1",
|
||||
"jsdom": "^9.8.3",
|
||||
"json-loader": "^0.5.4",
|
||||
@@ -142,15 +144,15 @@
|
||||
"mocha-junit-reporter": "^1.12.1",
|
||||
"nightwatch": "^0.9.11",
|
||||
"nodemon": "^1.11.0",
|
||||
"postcss-loader": "^1.1.0",
|
||||
"postcss-loader": "^1.3.3",
|
||||
"postcss-modules": "^0.5.2",
|
||||
"postcss-smart-import": "^0.5.1",
|
||||
"pre-git": "^3.10.0",
|
||||
"precss": "^1.4.0",
|
||||
"pym.js": "^1.1.1",
|
||||
"react": "15.3.2",
|
||||
"react-addons-test-utils": "15.3.2",
|
||||
"react-dom": "15.3.2",
|
||||
"react": "^15.4.2",
|
||||
"react-addons-test-utils": "^15.4.2",
|
||||
"react-dom": "^15.4.2",
|
||||
"react-highlight-words": "^0.6.0",
|
||||
"react-linkify": "^0.1.3",
|
||||
"react-mdl": "^1.7.2",
|
||||
@@ -164,10 +166,10 @@
|
||||
"redux-thunk": "^2.1.0",
|
||||
"regenerator": "^0.8.46",
|
||||
"selenium-standalone": "^5.11.2",
|
||||
"style-loader": "^0.13.1",
|
||||
"style-loader": "^0.16.0",
|
||||
"supertest": "^2.0.1",
|
||||
"timeago.js": "^2.0.3",
|
||||
"webpack": "^2.2.1"
|
||||
"webpack": "^2.3.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^7.7.0"
|
||||
|
||||
+12
-2
@@ -57,11 +57,21 @@ module.exports = class AssetsService {
|
||||
static findOrCreateByUrl(url) {
|
||||
|
||||
// Check the URL to confirm that is in the domain whitelist
|
||||
return domainlist.urlCheck(url).then((whitelisted) => {
|
||||
return Promise.all([
|
||||
domainlist.urlCheck(url),
|
||||
SettingsService.retrieve()
|
||||
]).then(([whitelisted, settings]) => {
|
||||
|
||||
const update = {$setOnInsert: {url}};
|
||||
|
||||
if (settings.autoCloseStream) {
|
||||
update.$setOnInsert.closedAt = new Date(Date.now() + settings.closedTimeout * 1000);
|
||||
}
|
||||
|
||||
if (!whitelisted) {
|
||||
return Promise.reject(errors.ErrInvalidAssetURL);
|
||||
} else {
|
||||
return AssetModel.findOneAndUpdate({url}, {url}, {
|
||||
return AssetModel.findOneAndUpdate({url}, update, {
|
||||
|
||||
// Ensure that if it's new, we return the new object created.
|
||||
new: true,
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<%= body %>
|
||||
@@ -0,0 +1 @@
|
||||
<%= body %>
|
||||
+35
-10
@@ -17,16 +17,41 @@ if (smtpRequiredProps.some(prop => !process.env[prop])) {
|
||||
}
|
||||
|
||||
// load all the templates as strings
|
||||
const templateStrings = {};
|
||||
fs.readdir(path.join(__dirname, 'email'), (err, files) => {
|
||||
if (err) {
|
||||
throw err;
|
||||
const templates = {
|
||||
data: {}
|
||||
};
|
||||
|
||||
// load the temlates per request during development
|
||||
templates.render = (name, format = 'txt', context) => new Promise((resolve, reject) => {
|
||||
|
||||
// If we are in production mode, check the view cache.
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
if (name in templates.data && format in templates.data[name]) {
|
||||
let view = templates.data[name][format];
|
||||
|
||||
return resolve(view(context));
|
||||
}
|
||||
}
|
||||
|
||||
files.forEach(file => {
|
||||
fs.readFile(path.join(__dirname, 'email', file), 'utf8', (err, data) => {
|
||||
templateStrings[file] = _.template(data);
|
||||
});
|
||||
const filename = path.join(__dirname, 'email', [name, format, 'ejs'].join('.'));
|
||||
|
||||
fs.readFile(filename, (err, file) => {
|
||||
if (err) {
|
||||
return reject(err);
|
||||
}
|
||||
|
||||
let view = _.template(file);
|
||||
|
||||
// If we are in production mode, fill the view cache.
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
if (!(name in templates.data)) {
|
||||
templates.data[name] = {};
|
||||
}
|
||||
|
||||
templates.data[name][format] = view;
|
||||
}
|
||||
|
||||
return resolve(view(context));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -70,10 +95,10 @@ const mailer = module.exports = {
|
||||
return Promise.all([
|
||||
|
||||
// Render the HTML version of the email.
|
||||
templateStrings[`${template}.ejs`](locals),
|
||||
templates.render(template, 'html', locals),
|
||||
|
||||
// Render the TEXT version of the email.
|
||||
templateStrings[`${template}.txt.ejs`](locals)
|
||||
templates.render(template, 'txt', locals)
|
||||
])
|
||||
.then(([html, text]) => {
|
||||
|
||||
|
||||
@@ -2,8 +2,11 @@ const bcrypt = require('bcrypt');
|
||||
const url = require('url');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const Wordlist = require('./wordlist');
|
||||
|
||||
const errors = require('../errors');
|
||||
|
||||
const uuid = require('uuid');
|
||||
|
||||
const redis = require('./redis');
|
||||
const redisClient = redis.createClient();
|
||||
|
||||
@@ -16,6 +19,7 @@ const RECAPTCHA_INCORRECT_TRIGGER = 5; // after 3 incorrect attempts, recaptcha
|
||||
|
||||
const SettingsService = require('./settings');
|
||||
const ActionsService = require('./actions');
|
||||
const MailerService = require('./mailer');
|
||||
|
||||
// In the event that the TALK_SESSION_SECRET is missing but we are testing, then
|
||||
// set the process.env.TALK_SESSION_SECRET.
|
||||
@@ -448,6 +452,44 @@ module.exports = class UsersService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Suspend a user. It changes the status to BANNED and canEditName to True.
|
||||
* @param {String} id id of a user
|
||||
* @param {Function} done callback after the operation is complete
|
||||
*/
|
||||
static suspendUser(id, message) {
|
||||
return UserModel.findOneAndUpdate({
|
||||
id
|
||||
}, {
|
||||
$set: {
|
||||
status: 'BANNED',
|
||||
canEditName: true
|
||||
}
|
||||
})
|
||||
.then((user) => {
|
||||
if (message) {
|
||||
let localProfile = user.profiles.find((profile) => profile.provider === 'local');
|
||||
|
||||
if (localProfile) {
|
||||
const options =
|
||||
{
|
||||
template: 'suspension', // needed to know which template to render!
|
||||
locals: { // specifies the template locals.
|
||||
body: message
|
||||
},
|
||||
subject: 'Email Suspension',
|
||||
to: localProfile.id // This only works if the user has registered via e-mail.
|
||||
// We may want a standard way to access a user's e-mail address in the future
|
||||
};
|
||||
|
||||
return MailerService.sendSimple(options);
|
||||
} else {
|
||||
return Promise.reject(errors.ErrMissingEmail);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a user with the id.
|
||||
* @param {String} id user id (uuid)
|
||||
|
||||
+36
-16
@@ -2,9 +2,13 @@ const debug = require('debug')('talk:services:wordlist');
|
||||
const _ = require('lodash');
|
||||
const natural = require('natural');
|
||||
const tokenizer = new natural.WordTokenizer();
|
||||
const nameTokenizer = new natural.RegexpTokenizer({pattern: /\_/});
|
||||
const SettingsService = require('./settings');
|
||||
const Errors = require('../errors');
|
||||
|
||||
// REGEX to prevent emoji's from entering the wordlist.
|
||||
const EMOJI_REGEX = /(?:[\u2700-\u27bf]|(?:\ud83c[\udde6-\uddff]){2}|[\ud800-\udbff][\udc00-\udfff])[\ufe0e\ufe0f]?(?:[\u0300-\u036f\ufe20-\ufe23\u20d0-\u20f0]|\ud83c[\udffb-\udfff])?(?:\u200d(?:[^\ud800-\udfff]|(?:\ud83c[\udde6-\uddff]){2}|[\ud800-\udbff][\udc00-\udfff])[\ufe0e\ufe0f]?(?:[\u0300-\u036f\ufe20-\ufe23\u20d0-\u20f0]|\ud83c[\udffb-\udfff])?)*/;
|
||||
|
||||
/**
|
||||
* The root wordlist object.
|
||||
* @type {Object}
|
||||
@@ -58,7 +62,27 @@ class Wordlist {
|
||||
* @return {Array} the parsed list
|
||||
*/
|
||||
static parseList(list) {
|
||||
return _.uniq(list.map((word) => tokenizer.tokenize(word.toLowerCase())));
|
||||
return _.uniq(list.filter((word) => {
|
||||
if (EMOJI_REGEX.test(word)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
})
|
||||
.map((word) => {
|
||||
if (word.length === 1) {
|
||||
return [word];
|
||||
}
|
||||
|
||||
return tokenizer.tokenize(word.toLowerCase());
|
||||
})
|
||||
.filter((tokens) => {
|
||||
if (tokens.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -66,11 +90,11 @@ class Wordlist {
|
||||
* @param {String} phrase value to check for blockwords.
|
||||
* @return {Boolean} true if a blockword is found, false otherwise.
|
||||
*/
|
||||
match(list, phrase) {
|
||||
match(list, phrase, tk = tokenizer) {
|
||||
|
||||
// Lowercase the word to ensure that we don't miss a match due to
|
||||
// capitalization.
|
||||
let lowerPhraseWords = tokenizer.tokenize(phrase.toLowerCase());
|
||||
let lowerPhraseWords = tk.tokenize(phrase.toLowerCase());
|
||||
|
||||
// This will return true in the event that at least one blockword is found
|
||||
// in the phrase.
|
||||
@@ -199,28 +223,24 @@ class Wordlist {
|
||||
}
|
||||
|
||||
/**
|
||||
* check potential username for banned words, special characters
|
||||
* check potential username for banned words
|
||||
*/
|
||||
static usernameCheck(username) {
|
||||
const wl = new Wordlist();
|
||||
|
||||
return wl.load()
|
||||
return wl
|
||||
.load()
|
||||
.then(() => {
|
||||
username = username.replace(/_/g, '');
|
||||
|
||||
// test each word, and fail if we find a match
|
||||
const hasBadWords = wl.lists.banned.some(phrase => {
|
||||
return username.indexOf(phrase.join('')) !== -1;
|
||||
});
|
||||
|
||||
if (hasBadWords) {
|
||||
throw Errors.ErrContainsProfanity;
|
||||
} else {
|
||||
return Promise.resolve(username);
|
||||
if (!wl.checkName(wl.lists.banned, username)) {
|
||||
return Errors.ErrContainsProfanity;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
checkName(list, name) {
|
||||
return !this.match(list, name, nameTokenizer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect middleware for scanning request bodies for wordlisted words and
|
||||
* attaching a ErrContainsProfanity to the req.wordlisted parameter, otherwise
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "../../client/.babelrc"
|
||||
}
|
||||
@@ -54,7 +54,7 @@ describe('graph.mutations.addCommentTag', () => {
|
||||
}
|
||||
expect(response.errors).to.be.empty;
|
||||
expect(response.data.addCommentTag.errors).to.deep.equal([{'translation_key':'NOT_AUTHORIZED'}]);
|
||||
expect(response.data.addCommentTag.comment).to.be.null;
|
||||
expect(response.data.addCommentTag.comment).to.be.null;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -249,6 +249,17 @@ describe('services.UsersService', () => {
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('should not allow non-alphanumeric characters in usernames', () => {
|
||||
return UsersService
|
||||
.isValidUsername('hi🖕')
|
||||
.then(() => {
|
||||
expect(false).to.be.true;
|
||||
})
|
||||
.catch((err) => {
|
||||
expect(err).to.be.truthy;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -9,7 +9,8 @@ describe('services.Wordlist', () => {
|
||||
banned: [
|
||||
'cookies',
|
||||
'how to do bad things',
|
||||
'how to do really bad things'
|
||||
'how to do really bad things',
|
||||
's h i t'
|
||||
],
|
||||
suspect: [
|
||||
'do bad things'
|
||||
@@ -32,9 +33,22 @@ describe('services.Wordlist', () => {
|
||||
|
||||
});
|
||||
|
||||
describe('#match', () => {
|
||||
describe('#parseList', () => {
|
||||
it('does not include emojis in the wordlist', () => {
|
||||
let list = Wordlist.parseList([
|
||||
'🖕',
|
||||
'🖕 asdf',
|
||||
'asd🖕asdf',
|
||||
'asd🖕',
|
||||
]);
|
||||
|
||||
const bannedList = Wordlist.parseList(wordlists.banned);
|
||||
expect(list).to.have.length(0);
|
||||
});
|
||||
});
|
||||
|
||||
const bannedList = Wordlist.parseList(wordlists.banned);
|
||||
|
||||
describe('#match', () => {
|
||||
|
||||
it('does match on a bad word', () => {
|
||||
[
|
||||
@@ -62,6 +76,26 @@ describe('services.Wordlist', () => {
|
||||
|
||||
});
|
||||
|
||||
describe('#checkName', () => {
|
||||
[
|
||||
'flowers',
|
||||
'joy',
|
||||
'lots_of_candy'
|
||||
].forEach((username) => {
|
||||
it(`does not match on list=banned name=${username}`, () => {
|
||||
expect(wordlist.checkName(bannedList, username)).to.be.true;
|
||||
});
|
||||
});
|
||||
|
||||
[
|
||||
'cookies'
|
||||
].forEach((username) => {
|
||||
it(`does match on list=banned name=${username}`, () => {
|
||||
expect(wordlist.checkName(bannedList, username)).to.be.false;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('#filter', () => {
|
||||
|
||||
before(() => wordlist.upsert(wordlists));
|
||||
|
||||
+1
-1
@@ -21,7 +21,7 @@
|
||||
<meta name="msapplication-TileColor" content="#ffffff">
|
||||
<link href="https://fonts.googleapis.com/css?family=Roboto:300,400,500" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons">
|
||||
<link rel="stylesheet" href="https://code.getmdl.io/1.2.1/material.indigo-pink.min.css">
|
||||
<link rel="stylesheet" href="https://code.getmdl.io/1.2.1/material.min.css">
|
||||
<style media="screen">
|
||||
body, #root {
|
||||
width: 100%;
|
||||
|
||||
+2
-3
@@ -17,7 +17,7 @@ const buildEmbeds = [
|
||||
];
|
||||
|
||||
module.exports = {
|
||||
devtool: '#cheap-module-source-map',
|
||||
devtool: 'cheap-module-source-map',
|
||||
entry: Object.assign({}, {
|
||||
'embed': [
|
||||
'babel-polyfill',
|
||||
@@ -58,8 +58,7 @@ module.exports = {
|
||||
exclude: /node_modules/,
|
||||
test: /\.js$/,
|
||||
query: {
|
||||
cacheDirectory: true,
|
||||
sourceMap: true
|
||||
cacheDirectory: true
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user