mirror of
https://github.com/wassname/talk.git
synced 2026-07-26 13:37:38 +08:00
Merge remote-tracking branch 'origin/master' into dismiss-shortcuts-help-menu
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
# Snyk (https://snyk.io) policy file, patches or ignores known vulnerabilities.
|
||||
version: v1.7.1
|
||||
ignore: {}
|
||||
# patches apply the minimum changes required to fix a vulnerability
|
||||
patch:
|
||||
'npm:marked:20170112':
|
||||
- marked:
|
||||
patched: '2017-10-11T02:07:15.455Z'
|
||||
- graphql-docs > marked:
|
||||
patched: '2017-10-11T02:07:15.455Z'
|
||||
- simplemde > marked:
|
||||
patched: '2017-10-11T02:07:15.455Z'
|
||||
@@ -18,6 +18,13 @@ Check out our Docs: https://coralproject.github.io/talk/
|
||||
- Project: https://coralproject.net/
|
||||
- Roadmap: https://www.pivotaltracker.com/n/projects/1863625
|
||||
|
||||
## End-to-End Testing
|
||||
|
||||
Talk uses [Nightwatch](http://nightwatchjs.org/) to write e2e tests. The testing infrastructure that allows us to run our tests in real browsers is provided with love by our friends at Browserstack.
|
||||
|
||||
|
||||

|
||||
|
||||
## License
|
||||
|
||||
Copyright 2017 Mozilla Foundation
|
||||
@@ -26,8 +33,12 @@ Check out our Docs: https://coralproject.github.io/talk/
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
Unless required by applicable law or agreed to in writing,
|
||||
software distributed under the License is distributed on an
|
||||
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
|
||||
either express or implied.
|
||||
|
||||
See the License for the specific language governing permissions and limitations under the License.
|
||||
See the License for the specific language governing permissions
|
||||
and limitations under the License.
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import * as actions from 'constants/configure';
|
||||
|
||||
export const updatePending = ({updater, errorUpdater}) => {
|
||||
return {type: actions.UPDATE_PENDING, updater, errorUpdater};
|
||||
};
|
||||
|
||||
export const clearPending = () => {
|
||||
return {type: actions.CLEAR_PENDING};
|
||||
};
|
||||
|
||||
export const setActiveSection = (section) => {
|
||||
return {type: actions.SET_ACTIVE_SECTION, section};
|
||||
};
|
||||
@@ -1,58 +0,0 @@
|
||||
import t from 'coral-framework/services/i18n';
|
||||
|
||||
export const SETTINGS_LOADING = 'SETTINGS_LOADING';
|
||||
export const SETTINGS_RECEIVED = 'SETTINGS_RECEIVED';
|
||||
export const SETTINGS_FETCH_ERROR = 'SETTINGS_FETCH_ERROR';
|
||||
|
||||
export const SETTINGS_UPDATED = 'SETTINGS_UPDATED';
|
||||
|
||||
export const SAVE_SETTINGS_LOADING = 'SAVE_SETTINGS_LOADING';
|
||||
export const SAVE_SETTINGS_SUCCESS = 'SAVE_SETTINGS_SUCCESS';
|
||||
export const SAVE_SETTINGS_FAILED = 'SAVE_SETTINGS_FAILED';
|
||||
|
||||
export const WORDLIST_UPDATED = 'WORDLIST_UPDATED';
|
||||
export const DOMAINLIST_UPDATED = 'DOMAINLIST_UPDATED';
|
||||
|
||||
export const fetchSettings = () => (dispatch, _, {rest}) => {
|
||||
dispatch({type: SETTINGS_LOADING});
|
||||
rest('/settings')
|
||||
.then((settings) => {
|
||||
dispatch({type: SETTINGS_RECEIVED, settings});
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
const errorMessage = error.translation_key ? t(`error.${error.translation_key}`) : error.toString();
|
||||
dispatch({type: SETTINGS_FETCH_ERROR, error: errorMessage});
|
||||
});
|
||||
};
|
||||
|
||||
// for updating top-level settings
|
||||
export const updateSettings = (settings) => {
|
||||
return {type: SETTINGS_UPDATED, settings};
|
||||
};
|
||||
|
||||
// this is a nested property, so it needs a special action.
|
||||
export const updateWordlist = (listName, list) => {
|
||||
return {type: WORDLIST_UPDATED, listName, list};
|
||||
};
|
||||
|
||||
export const updateDomainlist = (listName, list) => {
|
||||
return {type: DOMAINLIST_UPDATED, listName, list};
|
||||
};
|
||||
|
||||
export const saveSettingsToServer = () => (dispatch, getState, {rest}) => {
|
||||
let settings = getState().settings;
|
||||
if (settings.charCount) {
|
||||
settings.charCount = parseInt(settings.charCount);
|
||||
}
|
||||
dispatch({type: SAVE_SETTINGS_LOADING});
|
||||
rest('/settings', {method: 'PUT', body: settings})
|
||||
.then(() => {
|
||||
dispatch({type: SAVE_SETTINGS_SUCCESS, settings});
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
const errorMessage = error.translation_key ? t(`error.${error.translation_key}`) : error.toString();
|
||||
dispatch({type: SAVE_SETTINGS_FAILED, error: errorMessage});
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
@custom-media --table-viewport (max-width: 1024px);
|
||||
|
||||
:global {
|
||||
.mdl-layout__drawer-button {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
@media (--table-viewport) {
|
||||
.mdl-layout__drawer-button {
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
}
|
||||
+4
-3
@@ -6,8 +6,8 @@ import styles from './Drawer.css';
|
||||
import t from 'coral-framework/services/i18n';
|
||||
import {can} from 'coral-framework/services/perms';
|
||||
|
||||
const CoralDrawer = ({handleLogout, auth}) => (
|
||||
<Drawer className={styles.header}>
|
||||
const CoralDrawer = ({handleLogout, auth = {}}) => (
|
||||
<Drawer className={styles.drawer}>
|
||||
{ auth && auth.user && can(auth.user, 'ACCESS_ADMIN') ?
|
||||
<div>
|
||||
<Navigation className={styles.nav}>
|
||||
@@ -57,7 +57,8 @@ const CoralDrawer = ({handleLogout, auth}) => (
|
||||
|
||||
CoralDrawer.propTypes = {
|
||||
handleLogout: PropTypes.func.isRequired,
|
||||
restricted: PropTypes.bool // hide app elements from a logged out user
|
||||
restricted: PropTypes.bool, // hide app elements from a logged out user
|
||||
auth: PropTypes.object
|
||||
};
|
||||
|
||||
export default CoralDrawer;
|
||||
@@ -21,7 +21,6 @@
|
||||
|
||||
.callToAction {
|
||||
position: fixed;
|
||||
left: 10px;
|
||||
bottom: 10px;
|
||||
width: 280px;
|
||||
height: 200px;
|
||||
@@ -29,6 +28,7 @@
|
||||
padding: 15px;
|
||||
box-sizing: border-box;
|
||||
box-shadow: 0px 3px 5px 0px rgba(0,0,0,0.15);
|
||||
z-index: 10;
|
||||
|
||||
.ctaHeader {
|
||||
font-size: 16px;
|
||||
|
||||
@@ -17,8 +17,6 @@ export default class UserDetail extends React.Component {
|
||||
userId: PropTypes.string.isRequired,
|
||||
hideUserDetail: PropTypes.func.isRequired,
|
||||
root: PropTypes.object.isRequired,
|
||||
bannedWords: PropTypes.array.isRequired,
|
||||
suspectWords: PropTypes.array.isRequired,
|
||||
acceptComment: PropTypes.func.isRequired,
|
||||
rejectComment: PropTypes.func.isRequired,
|
||||
changeStatus: PropTypes.func.isRequired,
|
||||
@@ -79,8 +77,6 @@ export default class UserDetail extends React.Component {
|
||||
},
|
||||
activeTab,
|
||||
selectedCommentIds,
|
||||
bannedWords,
|
||||
suspectWords,
|
||||
toggleSelect,
|
||||
bulkAccept,
|
||||
bulkReject,
|
||||
@@ -184,8 +180,6 @@ export default class UserDetail extends React.Component {
|
||||
root={root}
|
||||
data={data}
|
||||
comment={comment}
|
||||
suspectWords={suspectWords}
|
||||
bannedWords={bannedWords}
|
||||
acceptComment={this.acceptThenReload}
|
||||
rejectComment={this.rejectThenReload}
|
||||
selected={selected}
|
||||
|
||||
@@ -30,12 +30,11 @@ class UserDetailComment extends React.Component {
|
||||
render() {
|
||||
const {
|
||||
comment,
|
||||
suspectWords,
|
||||
bannedWords,
|
||||
selected,
|
||||
toggleSelect,
|
||||
className,
|
||||
data,
|
||||
root: {settings: {wordlist: {banned, suspect}}},
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
@@ -72,8 +71,8 @@ class UserDetailComment extends React.Component {
|
||||
<div className={styles.bodyContainer}>
|
||||
<div className={styles.body}>
|
||||
<CommentBodyHighlighter
|
||||
suspectWords={suspectWords}
|
||||
bannedWords={bannedWords}
|
||||
suspectWords={suspect}
|
||||
bannedWords={banned}
|
||||
body={comment.body}
|
||||
/>
|
||||
{' '}
|
||||
@@ -123,9 +122,15 @@ UserDetailComment.propTypes = {
|
||||
acceptComment: PropTypes.func.isRequired,
|
||||
rejectComment: PropTypes.func.isRequired,
|
||||
className: PropTypes.string,
|
||||
suspectWords: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||
bannedWords: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||
toggleSelect: PropTypes.func,
|
||||
root: PropTypes.shape({
|
||||
settings: PropTypes.shape({
|
||||
wordlist: PropTypes.shape({
|
||||
suspect: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||
banned: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
comment: PropTypes.shape({
|
||||
id: PropTypes.string.isRequired,
|
||||
status: PropTypes.string.isRequired,
|
||||
@@ -136,8 +141,8 @@ UserDetailComment.propTypes = {
|
||||
title: PropTypes.string,
|
||||
url: PropTypes.string,
|
||||
id: PropTypes.string
|
||||
})
|
||||
})
|
||||
}),
|
||||
}),
|
||||
};
|
||||
|
||||
export default UserDetailComment;
|
||||
|
||||
@@ -15,10 +15,16 @@
|
||||
}
|
||||
}
|
||||
|
||||
.headerWrapper {
|
||||
background-color: #696969;
|
||||
}
|
||||
|
||||
.header {
|
||||
box-shadow: none;
|
||||
min-height: 58px;
|
||||
display: block;
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
background-color: #696969;
|
||||
box-shadow: 0 2px 2px 0 rgba(0,0,0,.14), 0 3px 1px -2px rgba(0,0,0,.2), 0 1px 5px 0 rgba(0,0,0,.12);
|
||||
}
|
||||
@@ -26,7 +32,7 @@
|
||||
.header > div {
|
||||
position: relative;
|
||||
padding: 0;
|
||||
width: 1280px;
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
height: 58px;
|
||||
}
|
||||
|
||||
@@ -15,93 +15,95 @@ const CoralHeader = ({
|
||||
root
|
||||
}) => {
|
||||
return (
|
||||
<Header className={styles.header}>
|
||||
<Logo className={styles.logo} />
|
||||
<div>
|
||||
{
|
||||
auth && auth.user && can(auth.user, 'ACCESS_ADMIN') ?
|
||||
<Navigation className={styles.nav}>
|
||||
<IndexLink
|
||||
id='dashboardNav'
|
||||
className={styles.navLink}
|
||||
to="/admin/dashboard"
|
||||
activeClassName={styles.active}>
|
||||
{t('configure.dashboard')}
|
||||
</IndexLink>
|
||||
{
|
||||
can(auth.user, 'MODERATE_COMMENTS') && (
|
||||
<Link
|
||||
id='moderateNav'
|
||||
className={styles.navLink}
|
||||
to="/admin/moderate"
|
||||
activeClassName={styles.active}>
|
||||
{t('configure.moderate')}
|
||||
{(root.premodCount !== 0 || root.reportedCount !== 0) && <Indicator />}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
<Link
|
||||
id='streamsNav'
|
||||
className={styles.navLink}
|
||||
to="/admin/stories"
|
||||
activeClassName={styles.active}>
|
||||
{t('configure.stories')}
|
||||
</Link>
|
||||
<div className={styles.headerWrapper}>
|
||||
<Header className={styles.header}>
|
||||
<Logo className={styles.logo} />
|
||||
<div>
|
||||
{
|
||||
auth && auth.user && can(auth.user, 'ACCESS_ADMIN') ?
|
||||
<Navigation className={styles.nav}>
|
||||
<IndexLink
|
||||
id='dashboardNav'
|
||||
className={styles.navLink}
|
||||
to="/admin/dashboard"
|
||||
activeClassName={styles.active}>
|
||||
{t('configure.dashboard')}
|
||||
</IndexLink>
|
||||
{
|
||||
can(auth.user, 'MODERATE_COMMENTS') && (
|
||||
<Link
|
||||
id='moderateNav'
|
||||
className={styles.navLink}
|
||||
to="/admin/moderate"
|
||||
activeClassName={styles.active}>
|
||||
{t('configure.moderate')}
|
||||
{(root.premodCount !== 0 || root.reportedCount !== 0) && <Indicator />}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
<Link
|
||||
id='streamsNav'
|
||||
className={styles.navLink}
|
||||
to="/admin/stories"
|
||||
activeClassName={styles.active}>
|
||||
{t('configure.stories')}
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
id='communityNav'
|
||||
className={styles.navLink}
|
||||
to="/admin/community"
|
||||
activeClassName={styles.active}>
|
||||
{t('configure.community')}
|
||||
{root.flaggedUsernamesCount !== 0 && <Indicator />}
|
||||
</Link>
|
||||
<Link
|
||||
id='communityNav'
|
||||
className={styles.navLink}
|
||||
to="/admin/community"
|
||||
activeClassName={styles.active}>
|
||||
{t('configure.community')}
|
||||
{root.flaggedUsernamesCount !== 0 && <Indicator />}
|
||||
</Link>
|
||||
|
||||
{
|
||||
can(auth.user, 'UPDATE_CONFIG') && (
|
||||
<Link
|
||||
id='configureNav'
|
||||
className={styles.navLink}
|
||||
to="/admin/configure"
|
||||
activeClassName={styles.active}>
|
||||
{t('configure.configure')}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
</Navigation>
|
||||
:
|
||||
null
|
||||
}
|
||||
<div className={styles.rightPanel}>
|
||||
<ul>
|
||||
<li className={styles.settings}>
|
||||
<div>
|
||||
<IconButton name="settings" id="menu-settings"/>
|
||||
<Menu target="menu-settings" align="right">
|
||||
<MenuItem onClick={() => showShortcuts(true)}>{t('configure.shortcuts')}</MenuItem>
|
||||
<MenuItem>
|
||||
<a href="https://github.com/coralproject/talk/releases" target="_blank" rel="noopener noreferrer">
|
||||
View latest version
|
||||
</a>
|
||||
</MenuItem>
|
||||
<MenuItem>
|
||||
<a href="https://coralproject.net/contribute.html#other-ideas-and-bug-reports" target="_blank" rel="noopener noreferrer">
|
||||
Report a bug or give feedback
|
||||
</a>
|
||||
</MenuItem>
|
||||
<MenuItem onClick={handleLogout}>
|
||||
{t('configure.sign_out')}
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
{`v${process.env.VERSION}`}
|
||||
</li>
|
||||
</ul>
|
||||
{
|
||||
can(auth.user, 'UPDATE_CONFIG') && (
|
||||
<Link
|
||||
id='configureNav'
|
||||
className={styles.navLink}
|
||||
to="/admin/configure"
|
||||
activeClassName={styles.active}>
|
||||
{t('configure.configure')}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
</Navigation>
|
||||
:
|
||||
null
|
||||
}
|
||||
<div className={styles.rightPanel}>
|
||||
<ul>
|
||||
<li className={styles.settings}>
|
||||
<div>
|
||||
<IconButton name="settings" id="menu-settings"/>
|
||||
<Menu target="menu-settings" align="right">
|
||||
<MenuItem onClick={() => showShortcuts(true)}>{t('configure.shortcuts')}</MenuItem>
|
||||
<MenuItem>
|
||||
<a href="https://github.com/coralproject/talk/releases" target="_blank" rel="noopener noreferrer">
|
||||
View latest version
|
||||
</a>
|
||||
</MenuItem>
|
||||
<MenuItem>
|
||||
<a href="https://coralproject.net/contribute.html#other-ideas-and-bug-reports" target="_blank" rel="noopener noreferrer">
|
||||
Report a bug or give feedback
|
||||
</a>
|
||||
</MenuItem>
|
||||
<MenuItem onClick={handleLogout}>
|
||||
{t('configure.sign_out')}
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
{`v${process.env.VERSION}`}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Header>
|
||||
</Header>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
.layout {
|
||||
margin: 0 auto;
|
||||
background-color: #FAFAFA;
|
||||
margin: 0 auto;
|
||||
background-color: #FAFAFA;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {Layout as LayoutMDL} from 'react-mdl';
|
||||
import Header from '../../containers/Header';
|
||||
import Drawer from './Drawer';
|
||||
import Drawer from '../Drawer';
|
||||
import styles from './Layout.css';
|
||||
|
||||
const Layout = ({
|
||||
@@ -21,6 +21,7 @@ const Layout = ({
|
||||
<Drawer
|
||||
handleLogout={handleLogout}
|
||||
restricted={restricted}
|
||||
auth={auth}
|
||||
/>
|
||||
<div className={styles.layout}>
|
||||
{children}
|
||||
|
||||
@@ -20,7 +20,6 @@
|
||||
background: #696969;
|
||||
height: 100%;
|
||||
width: 128px;
|
||||
z-index: 10;
|
||||
border-right: 1px #757575 solid;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
const prefix = 'TALK_ADMIN_CONFIGURE';
|
||||
|
||||
export const UPDATE_PENDING = `${prefix}_UPDATE_PENDING`;
|
||||
export const CLEAR_PENDING = `${prefix}_CLEAR_PENDING`;
|
||||
export const SET_ACTIVE_SECTION = `${prefix}_SET_ACTIVE_SECTION`;
|
||||
@@ -179,8 +179,6 @@ const mapStateToProps = (state) => ({
|
||||
selectedCommentIds: state.userDetail.selectedCommentIds,
|
||||
statuses: state.userDetail.statuses,
|
||||
activeTab: state.userDetail.activeTab,
|
||||
bannedWords: state.settings.wordlist.banned,
|
||||
suspectWords: state.settings.wordlist.suspect,
|
||||
});
|
||||
|
||||
const mapDispatchToProps = (dispatch) => ({
|
||||
|
||||
@@ -8,7 +8,12 @@ import CommentDetails from './CommentDetails';
|
||||
export default withFragments({
|
||||
root: gql`
|
||||
fragment CoralAdmin_UserDetailComment_root on RootQuery {
|
||||
__typename
|
||||
settings {
|
||||
wordlist {
|
||||
banned
|
||||
suspect
|
||||
}
|
||||
}
|
||||
...${getDefinitionName(CommentLabels.fragments.root)}
|
||||
...${getDefinitionName(CommentDetails.fragments.root)}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,15 @@
|
||||
import update from 'immutability-helper';
|
||||
import mapValues from 'lodash/mapValues';
|
||||
|
||||
// Map nested object leaves. Array objects are considered leaves.
|
||||
function mapLeaves(o, mapper) {
|
||||
return mapValues(o, (val) => {
|
||||
if (typeof val === 'object' && !Array.isArray(val)) {
|
||||
return mapLeaves(val, mapper);
|
||||
}
|
||||
return mapper(val);
|
||||
});
|
||||
}
|
||||
|
||||
export default {
|
||||
mutations: {
|
||||
@@ -29,6 +40,16 @@ export default {
|
||||
}
|
||||
}
|
||||
}),
|
||||
UpdateSettings: ({variables: {input}}) => ({
|
||||
updateQueries: {
|
||||
TalkAdmin_Configure: (prev) => {
|
||||
const updated = update(prev, {
|
||||
settings: mapLeaves(input, (leaf) => ({$set: leaf})),
|
||||
});
|
||||
return updated;
|
||||
}
|
||||
}
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import * as actions from '../constants/configure';
|
||||
import isEmpty from 'lodash/isEmpty';
|
||||
import update from 'immutability-helper';
|
||||
|
||||
const initialState = {
|
||||
canSave: false,
|
||||
pending: {},
|
||||
errors: {},
|
||||
activeSection: 'stream',
|
||||
};
|
||||
|
||||
export default function configure(state = initialState, action) {
|
||||
switch (action.type) {
|
||||
case actions.UPDATE_PENDING: {
|
||||
let next = state;
|
||||
if (action.updater) {
|
||||
next = update(next, {
|
||||
pending: action.updater,
|
||||
});
|
||||
}
|
||||
if (action.errorUpdater) {
|
||||
next = update(next, {
|
||||
errors: action.errorUpdater,
|
||||
});
|
||||
}
|
||||
const noErrors = Object.keys(next.errors).reduce((res, error) => res && !next.errors[error], true);
|
||||
const canSave = !isEmpty(next.pending) && noErrors;
|
||||
next = update(next, {
|
||||
canSave: {$set: canSave},
|
||||
});
|
||||
|
||||
return next;
|
||||
}
|
||||
case actions.CLEAR_PENDING:
|
||||
return {
|
||||
...state,
|
||||
pending: {},
|
||||
canSave: false,
|
||||
};
|
||||
case actions.SET_ACTIVE_SECTION:
|
||||
return {
|
||||
...state,
|
||||
activeSection: action.section,
|
||||
};
|
||||
}
|
||||
return state;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
// this is initialized here because
|
||||
// currently you have to reload the dashboard to get new stats
|
||||
// cleaner updates are planned in the future.
|
||||
const DASHBOARD_WINDOW_MINUTES = 5;
|
||||
let then = new Date();
|
||||
then.setMinutes(then.getMinutes() - DASHBOARD_WINDOW_MINUTES);
|
||||
|
||||
const initialState = {
|
||||
windowStart: then.toISOString(),
|
||||
windowEnd: new Date().toISOString(),
|
||||
};
|
||||
|
||||
export default function dashboard (state = initialState, _action) {
|
||||
return state;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import auth from './auth';
|
||||
import assets from './assets';
|
||||
import settings from './settings';
|
||||
import dashboard from './dashboard';
|
||||
import configure from './configure';
|
||||
import community from './community';
|
||||
import moderation from './moderation';
|
||||
import install from './install';
|
||||
@@ -12,10 +13,11 @@ import userDetail from './userDetail';
|
||||
export default {
|
||||
auth,
|
||||
banUserDialog,
|
||||
dashboard,
|
||||
configure,
|
||||
suspendUserDialog,
|
||||
userDetail,
|
||||
assets,
|
||||
settings,
|
||||
community,
|
||||
moderation,
|
||||
install,
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
import * as actions from '../actions/settings';
|
||||
import update from 'immutability-helper';
|
||||
|
||||
// this is initialized here because
|
||||
// currently you have to reload the dashboard to get new stats
|
||||
// cleaner updates are planned in the future.
|
||||
// TODO: if there are more than two fields for the dashboard being created here,
|
||||
// please create a new reducer specifically for the Dashboard.
|
||||
const DASHBOARD_WINDOW_MINUTES = 5;
|
||||
let then = new Date();
|
||||
then.setMinutes(then.getMinutes() - DASHBOARD_WINDOW_MINUTES);
|
||||
|
||||
const initialState = {
|
||||
wordlist: {
|
||||
banned: [],
|
||||
suspect: []
|
||||
},
|
||||
dashboardWindowStart: then.toISOString(),
|
||||
dashboardWindowEnd: new Date().toISOString(),
|
||||
domains: {
|
||||
whitelist: []
|
||||
},
|
||||
saveSettingsError: null,
|
||||
fetchSettingsError: null,
|
||||
fetchingSettings: false
|
||||
};
|
||||
|
||||
export default function settings (state = initialState, action) {
|
||||
switch (action.type) {
|
||||
case actions.SETTINGS_LOADING:
|
||||
return {
|
||||
...state,
|
||||
fetchingSettings: true,
|
||||
fetchSettingsError: null,
|
||||
};
|
||||
case actions.SETTINGS_RECEIVED:
|
||||
return {
|
||||
...state,
|
||||
fetchingSettings: false,
|
||||
fetchSettingsError: null,
|
||||
...action.settings
|
||||
};
|
||||
case actions.SETTINGS_FETCH_ERROR:
|
||||
return {
|
||||
...state,
|
||||
fetchingSettings: false,
|
||||
fetchSettingsError: action.error,
|
||||
};
|
||||
case actions.SETTINGS_UPDATED:
|
||||
return {
|
||||
...state,
|
||||
fetchingSettings: false,
|
||||
fetchSettingsError: null,
|
||||
...action.settings
|
||||
};
|
||||
case actions.SAVE_SETTINGS_LOADING:
|
||||
return {
|
||||
...state,
|
||||
fetchingSettings: true,
|
||||
saveSettingsError: null,
|
||||
};
|
||||
case actions.SAVE_SETTINGS_SUCCESS:
|
||||
return {
|
||||
...state,
|
||||
fetchingSettings: false,
|
||||
fetchSettingsError: null,
|
||||
...action.settings
|
||||
};
|
||||
case actions.SAVE_SETTINGS_FAILED:
|
||||
return {
|
||||
...state,
|
||||
fetchingSettings: false,
|
||||
fetchSettingsError: action.error,
|
||||
};
|
||||
case actions.WORDLIST_UPDATED:
|
||||
return update(state, {
|
||||
wordlist: {
|
||||
[action.listName]: {
|
||||
$set: action.list
|
||||
}
|
||||
}
|
||||
});
|
||||
case actions.DOMAINLIST_UPDATED:
|
||||
return update(state, {
|
||||
domains: {
|
||||
[action.listName]: {$set: action.list},
|
||||
}
|
||||
});
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import Table from '../containers/Table';
|
||||
import {Pager, Icon} from 'coral-ui';
|
||||
import EmptyCard from '../../../components/EmptyCard';
|
||||
import t from 'coral-framework/services/i18n';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
const tableHeaders = [
|
||||
{
|
||||
@@ -62,4 +63,12 @@ const People = ({commenters, searchValue, onSearchChange, ...props}) => {
|
||||
);
|
||||
};
|
||||
|
||||
People.propTypes = {
|
||||
commenters: PropTypes.array,
|
||||
searchValue: PropTypes.string,
|
||||
onSearchChange: PropTypes.func,
|
||||
totalPages: PropTypes.number,
|
||||
onNewPageHandler: PropTypes.func,
|
||||
};
|
||||
|
||||
export default People;
|
||||
|
||||
@@ -19,6 +19,13 @@
|
||||
}
|
||||
}
|
||||
|
||||
.username, .email {
|
||||
max-width: 215px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.email {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@@ -2,8 +2,10 @@ import React from 'react';
|
||||
import {SelectField, Option} from 'react-mdl-selectfield';
|
||||
import styles from '../components/Table.css';
|
||||
import t from 'coral-framework/services/i18n';
|
||||
import PropTypes from 'prop-types';
|
||||
import cn from 'classnames';
|
||||
|
||||
export default ({headers, commenters, onHeaderClickHandler, onRoleChange, onCommenterStatusChange, viewUserDetail}) => (
|
||||
const Table = ({headers, commenters, onHeaderClickHandler, onRoleChange, onCommenterStatusChange, viewUserDetail}) => (
|
||||
<table className={`mdl-data-table ${styles.dataTable}`}>
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -21,7 +23,7 @@ export default ({headers, commenters, onHeaderClickHandler, onRoleChange, onComm
|
||||
{commenters.map((row, i)=> (
|
||||
<tr key={i}>
|
||||
<td className="mdl-data-table__cell--non-numeric">
|
||||
<button onClick={() => {viewUserDetail(row.id);}} className={styles.button}>{row.username}</button>
|
||||
<button onClick={() => {viewUserDetail(row.id);}} className={cn(styles.username, styles.button)}>{row.username}</button>
|
||||
<span className={styles.email}>{row.profiles.map(({id}) => id)}</span>
|
||||
</td>
|
||||
<td className="mdl-data-table__cell--non-numeric">
|
||||
@@ -54,3 +56,14 @@ export default ({headers, commenters, onHeaderClickHandler, onRoleChange, onComm
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
|
||||
Table.propTypes = {
|
||||
headers: PropTypes.array,
|
||||
commenters: PropTypes.array,
|
||||
onHeaderClickHandler: PropTypes.func,
|
||||
onRoleChange: PropTypes.func,
|
||||
onCommenterStatusChange: PropTypes.func,
|
||||
viewUserDetail: PropTypes.func,
|
||||
};
|
||||
|
||||
export default Table;
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import React, {Component} from 'react';
|
||||
import {connect} from 'react-redux';
|
||||
import {bindActionCreators} from 'redux';
|
||||
import {compose} from 'react-apollo';
|
||||
import {setRole, setCommenterStatus} from '../../../actions/community';
|
||||
import Table from '../components/Table';
|
||||
import {viewUserDetail} from '../../../actions/userDetail';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
class TableContainer extends Component {
|
||||
|
||||
@@ -22,6 +22,12 @@ class TableContainer extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
TableContainer.propTypes = {
|
||||
setRole: PropTypes.func,
|
||||
setCommenterStatus: PropTypes.func,
|
||||
commenters: PropTypes.array,
|
||||
};
|
||||
|
||||
const mapStateToProps = (state) => ({
|
||||
commenters: state.community.accounts,
|
||||
});
|
||||
@@ -33,7 +39,5 @@ const mapDispatchToProps = (dispatch) =>
|
||||
viewUserDetail,
|
||||
}, dispatch);
|
||||
|
||||
export default compose(
|
||||
connect(mapStateToProps, mapDispatchToProps),
|
||||
)(TableContainer);
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(TableContainer);
|
||||
|
||||
|
||||
@@ -1,17 +1,7 @@
|
||||
/**
|
||||
* @TODO: deprecated as this file contains styles from multiple components. Please refactor.
|
||||
*/
|
||||
|
||||
.container {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
|
||||
h3 {
|
||||
color: black;
|
||||
font-size: 1.26em;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.leftColumn {
|
||||
@@ -19,108 +9,8 @@
|
||||
width: 234px;
|
||||
}
|
||||
|
||||
.mainContent {
|
||||
width: calc(100% - 300px);
|
||||
padding: 10px 14px;
|
||||
box-sizing: border-box;
|
||||
max-width: 718px;
|
||||
}
|
||||
|
||||
.configSetting {
|
||||
margin-bottom: 20px;
|
||||
align-items: flex-start;
|
||||
min-height: 100px;
|
||||
max-width: 600px;
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
|
||||
.copiedText {
|
||||
display: inline-block;
|
||||
color: #00796b;
|
||||
padding: 12px;
|
||||
font-size: 14px;
|
||||
float: right;
|
||||
}
|
||||
|
||||
.copyButton {
|
||||
display: inline-block;
|
||||
width: 200px;
|
||||
float: right;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.settingsError {
|
||||
color: #d50000;
|
||||
}
|
||||
|
||||
.settingsError i {
|
||||
font-size: 14px;
|
||||
margin-right: 3px;
|
||||
}
|
||||
|
||||
.settingsHeader {
|
||||
margin-top: 3px;
|
||||
margin-bottom: 7px;
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.disabledSettingText {
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.configSettingInfoBox {
|
||||
min-height: 100px;
|
||||
margin-bottom: 20px;
|
||||
width: auto;
|
||||
height: auto;
|
||||
text-align: left;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.configSettingEmbed {
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 10px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.configTimeoutSelect {
|
||||
display: inline-block;
|
||||
margin-left: 20px;
|
||||
|
||||
i { /* fix for firefox and react-mdl-selectfield@0.2.0 */
|
||||
padding: 20px 0;
|
||||
vertical-align: top;
|
||||
}
|
||||
}
|
||||
|
||||
.inlineTextfield {
|
||||
border-color: #ccc;
|
||||
border-style: solid;
|
||||
border-width: 0px 0px 1px 0px;
|
||||
text-align: center;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
.inlineTextfield:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.charCountTexfield, .editCommentTimeframeTextfield {
|
||||
width: 4em;
|
||||
padding: 0px;
|
||||
}
|
||||
|
||||
.charCountTexfieldEnabled {
|
||||
border-color: #00796b;
|
||||
.saveBox {
|
||||
margin-top: 38px;
|
||||
}
|
||||
|
||||
.changedSave {
|
||||
@@ -128,81 +18,10 @@
|
||||
color: white;
|
||||
}
|
||||
|
||||
.embedInput {
|
||||
width: 100%;
|
||||
display: block;
|
||||
outline: none;
|
||||
border: 1px solid rgba(0,0,0,.12);
|
||||
padding: 6px;
|
||||
.mainContent {
|
||||
width: calc(100% - 300px);
|
||||
padding: 10px 14px 80px 14px;
|
||||
box-sizing: border-box;
|
||||
border-radius: 2px;
|
||||
margin: 5px auto;
|
||||
min-height: 175px;
|
||||
font-size: 14px;
|
||||
resize: none;
|
||||
max-width: 718px;
|
||||
}
|
||||
|
||||
.customCSSInput {
|
||||
width: 100%;
|
||||
font-size: 14px;
|
||||
padding: 14px;
|
||||
letter-spacing: 0.03em;
|
||||
color: #555;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.enabledSetting {
|
||||
border-left-color: #00796b;
|
||||
border-left-style: solid;
|
||||
border-left-width: 7px;
|
||||
}
|
||||
|
||||
.disabledSetting {
|
||||
padding-left: 22px;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.saveBox {
|
||||
margin-top: 38px;
|
||||
}
|
||||
|
||||
.settingsSection {
|
||||
padding-bottom: 200px;
|
||||
.action {
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: inline-block;
|
||||
padding: 0px 30px;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.Configure {
|
||||
|
||||
p {
|
||||
line-height: 1.2;
|
||||
max-width: 550px;
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.descriptionBox {
|
||||
margin-top: 15px;
|
||||
|
||||
input {
|
||||
height: 150px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,119 +1,40 @@
|
||||
import React, {Component} from 'react';
|
||||
|
||||
import {Button, List, Item, Card, Spinner} from 'coral-ui';
|
||||
import {Button, List, Item} from 'coral-ui';
|
||||
import styles from './Configure.css';
|
||||
import StreamSettings from './StreamSettings';
|
||||
import ModerationSettings from './ModerationSettings';
|
||||
import TechSettings from './TechSettings';
|
||||
import StreamSettings from '../containers/StreamSettings';
|
||||
import ModerationSettings from '../containers/ModerationSettings';
|
||||
import TechSettings from '../containers/TechSettings';
|
||||
import t from 'coral-framework/services/i18n';
|
||||
import {can} from 'coral-framework/services/perms';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
export default class Configure extends Component {
|
||||
|
||||
state = {
|
||||
activeSection: 'stream',
|
||||
changed: false,
|
||||
errors: {}
|
||||
};
|
||||
|
||||
saveSettings = () => {
|
||||
this.props.saveSettingsToServer();
|
||||
this.setState({changed: false});
|
||||
}
|
||||
|
||||
changeSection = (activeSection) => {
|
||||
this.setState({activeSection});
|
||||
}
|
||||
|
||||
onChangeWordlist = (listName, list) => {
|
||||
this.setState({changed: true});
|
||||
this.props.updateWordlist(listName, list);
|
||||
}
|
||||
|
||||
onChangeDomainlist = (listName, list) => {
|
||||
this.setState({changed: true});
|
||||
this.props.updateDomainlist(listName, list);
|
||||
}
|
||||
|
||||
onSettingUpdate = (setting) => {
|
||||
this.setState({changed: true});
|
||||
this.props.updateSettings(setting);
|
||||
}
|
||||
|
||||
// Sets an arbitrary error string and a boolean state.
|
||||
// This allows the system to track multiple errors.
|
||||
onSettingError = (error, state) => {
|
||||
this.setState((prevState) => {
|
||||
prevState.errors[error] = state;
|
||||
return prevState;
|
||||
});
|
||||
}
|
||||
|
||||
getSection (section) {
|
||||
const pageTitle = this.getPageTitle(section);
|
||||
let sectionComponent;
|
||||
getSectionComponent(section) {
|
||||
switch(section){
|
||||
case 'stream':
|
||||
sectionComponent = <StreamSettings
|
||||
settings={this.props.settings}
|
||||
updateSettings={this.onSettingUpdate}
|
||||
errors={this.state.errors}
|
||||
settingsError={this.onSettingError}/>;
|
||||
break;
|
||||
return StreamSettings;
|
||||
case 'moderation':
|
||||
sectionComponent = <ModerationSettings
|
||||
onChangeWordlist={this.onChangeWordlist}
|
||||
settings={this.props.settings}
|
||||
updateSettings={this.onSettingUpdate} />;
|
||||
break;
|
||||
return ModerationSettings;
|
||||
case 'tech':
|
||||
sectionComponent = <TechSettings
|
||||
onChangeDomainlist={this.onChangeDomainlist}
|
||||
settings={this.props.settings}
|
||||
updateSettings={this.onSettingUpdate} />;
|
||||
}
|
||||
|
||||
if (this.props.settings.fetchingSettings) {
|
||||
return <Card shadow="4"><Spinner/>Loading settings...</Card>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.settingsSection}>
|
||||
<h3>{pageTitle}</h3>
|
||||
{sectionComponent}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
getPageTitle (section) {
|
||||
switch(section) {
|
||||
case 'stream':
|
||||
return t('configure.stream_settings');
|
||||
case 'moderation':
|
||||
return t('configure.moderation_settings');
|
||||
case 'tech':
|
||||
return t('configure.tech_settings');
|
||||
default:
|
||||
return '';
|
||||
return TechSettings;
|
||||
}
|
||||
throw new Error(`Unknown section ${section}`);
|
||||
}
|
||||
|
||||
render () {
|
||||
const {activeSection} = this.state;
|
||||
const section = this.getSection(activeSection);
|
||||
const {auth: {user}} = this.props;
|
||||
const {auth: {user}, canSave, savePending, setActiveSection, activeSection} = this.props;
|
||||
const SectionComponent = this.getSectionComponent(activeSection);
|
||||
|
||||
if (!can(user, 'UPDATE_CONFIG')) {
|
||||
return <p>You must be an administrator to access config settings. Please find the nearest Admin and ask them to level you up!</p>;
|
||||
}
|
||||
|
||||
const showSave = Object.keys(this.state.errors).reduce(
|
||||
(bool, error) => this.state.errors[error] ? false : bool, this.state.changed);
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.leftColumn}>
|
||||
<List onChange={this.changeSection} activeItem={activeSection}>
|
||||
<List onChange={setActiveSection} activeItem={activeSection}>
|
||||
<Item itemId='stream' icon='speaker_notes'>
|
||||
{t('configure.stream_settings')}
|
||||
</Item>
|
||||
@@ -126,10 +47,10 @@ export default class Configure extends Component {
|
||||
</List>
|
||||
<div className={styles.saveBox}>
|
||||
{
|
||||
showSave ?
|
||||
canSave ?
|
||||
<Button
|
||||
raised
|
||||
onClick={this.saveSettings}
|
||||
onClick={savePending}
|
||||
className={styles.changedSave}
|
||||
icon='check'
|
||||
full
|
||||
@@ -150,11 +71,25 @@ export default class Configure extends Component {
|
||||
|
||||
</div>
|
||||
<div className={styles.mainContent}>
|
||||
{ this.props.saveFetchingError }
|
||||
{ this.props.fetchSettingsError }
|
||||
{ section }
|
||||
<SectionComponent
|
||||
data={this.props.data}
|
||||
root={this.props.root}
|
||||
settings={this.props.settings}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Configure.propTypes = {
|
||||
notify: PropTypes.func.isRequired,
|
||||
savePending: PropTypes.func.isRequired,
|
||||
auth: PropTypes.object.isRequired,
|
||||
data: PropTypes.object.isRequired,
|
||||
root: PropTypes.object.isRequired,
|
||||
settings: PropTypes.object.isRequired,
|
||||
canSave: PropTypes.bool.isRequired,
|
||||
setActiveSection: PropTypes.func.isRequired,
|
||||
activeSection: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
.title {
|
||||
color: black;
|
||||
font-size: 1.26em;
|
||||
font-weight: 500;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import styles from './ConfigurePage.css';
|
||||
|
||||
const ConfigurePage = ({title, children, ...rest}) => (
|
||||
<div {...rest}>
|
||||
<h3 className={styles.title}>{title}</h3>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
ConfigurePage.propTypes = {
|
||||
title: PropTypes.string.isRequired,
|
||||
children: PropTypes.node,
|
||||
};
|
||||
|
||||
export default ConfigurePage;
|
||||
@@ -1,25 +1,25 @@
|
||||
import React from 'react';
|
||||
import {Card} from 'coral-ui';
|
||||
import styles from './Configure.css';
|
||||
import PropTypes from 'prop-types';
|
||||
import TagsInput from 'coral-admin/src/components/TagsInput';
|
||||
import t from 'coral-framework/services/i18n';
|
||||
import ConfigureCard from 'coral-framework/components/ConfigureCard';
|
||||
|
||||
const Domainlist = ({domains, onChangeDomainlist}) => {
|
||||
return (
|
||||
<Card id={styles.domainlist} className={styles.configSetting}>
|
||||
<div className={styles.wrapper}>
|
||||
<div className={styles.settingsHeader}>{t('configure.domain_list_title')}</div>
|
||||
<p className={styles.domainlistDesc}>{t('configure.domain_list_text')}</p>
|
||||
<div className={styles.wrapper}>
|
||||
<TagsInput
|
||||
value={domains}
|
||||
inputProps={{placeholder: 'URL'}}
|
||||
onChange={(tags) => onChangeDomainlist('whitelist', tags)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<ConfigureCard title={t('configure.domain_list_title')}>
|
||||
<p>{t('configure.domain_list_text')}</p>
|
||||
<TagsInput
|
||||
value={domains}
|
||||
inputProps={{placeholder: 'URL'}}
|
||||
onChange={(tags) => onChangeDomainlist('whitelist', tags)}
|
||||
/>
|
||||
</ConfigureCard>
|
||||
);
|
||||
};
|
||||
|
||||
Domainlist.propTypes = {
|
||||
domains: PropTypes.array.isRequired,
|
||||
onChangeDomainlist: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default Domainlist;
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
.embedInput {
|
||||
width: 100%;
|
||||
display: block;
|
||||
outline: none;
|
||||
border: 1px solid rgba(0,0,0,.12);
|
||||
padding: 6px;
|
||||
box-sizing: border-box;
|
||||
border-radius: 2px;
|
||||
margin: 5px auto;
|
||||
min-height: 175px;
|
||||
font-size: 14px;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.copiedText {
|
||||
display: inline-block;
|
||||
color: #00796b;
|
||||
padding: 12px;
|
||||
font-size: 14px;
|
||||
float: right;
|
||||
}
|
||||
|
||||
.copyButton {
|
||||
display: inline-block;
|
||||
width: 200px;
|
||||
float: right;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
}
|
||||
@@ -1,17 +1,14 @@
|
||||
import React, {Component} from 'react';
|
||||
import t from 'coral-framework/services/i18n';
|
||||
import join from 'url-join';
|
||||
import styles from './Configure.css';
|
||||
import {Button, Card} from 'coral-ui';
|
||||
import styles from './EmbedLink.css';
|
||||
import {Button} from 'coral-ui';
|
||||
import {BASE_URL} from 'coral-framework/constants/url';
|
||||
import ConfigureCard from 'coral-framework/components/ConfigureCard';
|
||||
|
||||
class EmbedLink extends Component {
|
||||
|
||||
constructor (props) {
|
||||
super(props);
|
||||
|
||||
this.state = {copied: false};
|
||||
}
|
||||
state = {copied: false};
|
||||
|
||||
copyToClipBoard = () => {
|
||||
const copyTextarea = document.querySelector(`.${styles.embedInput}`);
|
||||
@@ -38,21 +35,18 @@ class EmbedLink extends Component {
|
||||
"></script>
|
||||
`.trim();
|
||||
return (
|
||||
<Card shadow="2" className={styles.configSetting}>
|
||||
<div className={styles.wrapper}>
|
||||
<div className={styles.settingsHeader}>Embed Comment Stream</div>
|
||||
<p>{t('configure.copy_and_paste')}</p>
|
||||
<textarea rows={5} type='text' className={styles.embedInput} value={embedText} readOnly={true}/>
|
||||
<div className={styles.actions}>
|
||||
<Button raised className={styles.copyButton} onClick={this.copyToClipBoard} cStyle="black">
|
||||
{t('embedlink.copy')}
|
||||
</Button>
|
||||
<div className={styles.copiedText}>
|
||||
{this.state.copied && 'Copied!'}
|
||||
</div>
|
||||
<ConfigureCard title={'Embed Comment Stream'}>
|
||||
<p>{t('configure.copy_and_paste')}</p>
|
||||
<textarea rows={5} type='text' className={styles.embedInput} value={embedText} readOnly={true}/>
|
||||
<div className={styles.actions}>
|
||||
<Button raised className={styles.copyButton} onClick={this.copyToClipBoard} cStyle="black">
|
||||
{t('embedlink.copy')}
|
||||
</Button>
|
||||
<div className={styles.copiedText}>
|
||||
{this.state.copied && 'Copied!'}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</ConfigureCard>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,90 +1,90 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import styles from './Configure.css';
|
||||
import {Card} from 'coral-ui';
|
||||
import {Checkbox} from 'react-mdl';
|
||||
import Wordlist from './Wordlist';
|
||||
import Slot from 'coral-framework/components/Slot';
|
||||
import t from 'coral-framework/services/i18n';
|
||||
import ConfigurePage from './ConfigurePage';
|
||||
import ConfigureCard from 'coral-framework/components/ConfigureCard';
|
||||
|
||||
const updateModeration = (updateSettings, mod) => () => {
|
||||
const moderation = mod === 'PRE' ? 'POST' : 'PRE';
|
||||
updateSettings({moderation});
|
||||
};
|
||||
class ModerationSettings extends React.Component {
|
||||
|
||||
const updateEmailConfirmation = (updateSettings, verify) => () => {
|
||||
updateSettings({requireEmailConfirmation: !verify});
|
||||
};
|
||||
updateModeration = () => {
|
||||
const updater = {moderation: {$set: this.props.settings.moderation === 'PRE' ? 'POST' : 'PRE'}};
|
||||
this.props.updatePending({updater});
|
||||
};
|
||||
|
||||
const updatePremodLinksEnable = (updateSettings, premodLinks) => () => {
|
||||
const premodLinksEnable = !premodLinks;
|
||||
updateSettings({premodLinksEnable});
|
||||
};
|
||||
updateEmailConfirmation = () => {
|
||||
const updater = {requireEmailConfirmation: {$set: !this.props.settings.requireEmailConfirmation}};
|
||||
this.props.updatePending({updater});
|
||||
};
|
||||
|
||||
const ModerationSettings = ({settings, updateSettings, onChangeWordlist}) => {
|
||||
updatePremodLinksEnable = () => {
|
||||
const updater = {premodLinksEnable: {$set: !this.props.settings.premodLinksEnable}};
|
||||
this.props.updatePending({updater});
|
||||
};
|
||||
|
||||
// just putting this here for shorthand below
|
||||
const on = styles.enabledSetting;
|
||||
const off = styles.disabledSetting;
|
||||
updateWordlist = (listName, list) => {
|
||||
this.props.updatePending({updater: {
|
||||
wordlist: {$apply: (wordlist) => {
|
||||
const changeSet = {[listName]: list};
|
||||
if (!wordlist) {
|
||||
return changeSet;
|
||||
}
|
||||
return {
|
||||
...wordlist,
|
||||
...changeSet,
|
||||
};
|
||||
}},
|
||||
}});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.Configure}>
|
||||
<Card className={`${styles.configSetting} ${settings.requireEmailConfirmation ? on : off}`}>
|
||||
<div className={styles.action}>
|
||||
<Checkbox
|
||||
onChange={updateEmailConfirmation(updateSettings, settings.requireEmailConfirmation)}
|
||||
checked={settings.requireEmailConfirmation} />
|
||||
</div>
|
||||
<div className={styles.content}>
|
||||
<div className={styles.settingsHeader}>{t('configure.require_email_verification')}</div>
|
||||
<p className={settings.requireEmailConfirmation ? '' : styles.disabledSettingText}>
|
||||
{t('configure.require_email_verification_text')}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
<Card className={`${styles.configSetting} ${settings.moderation === 'PRE' ? on : off}`}>
|
||||
<div className={styles.action}>
|
||||
<Checkbox
|
||||
onChange={updateModeration(updateSettings, settings.moderation)}
|
||||
checked={settings.moderation === 'PRE'} />
|
||||
</div>
|
||||
<div className={styles.content}>
|
||||
<div className={styles.settingsHeader}>{t('configure.enable_pre_moderation')}</div>
|
||||
<p className={settings.moderation === 'PRE' ? '' : styles.disabledSettingText}>
|
||||
{t('configure.enable_pre_moderation_text')}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
<Card className={`${styles.configSetting} ${settings.premodLinksEnable ? on : off}`}>
|
||||
<div className={styles.action}>
|
||||
<Checkbox
|
||||
onChange={updatePremodLinksEnable(updateSettings, settings.premodLinksEnable)}
|
||||
checked={settings.premodLinksEnable} />
|
||||
</div>
|
||||
<div className={styles.content}>
|
||||
<div className={styles.settingsHeader}>{t('configure.enable_premod_links')}</div>
|
||||
<p>
|
||||
{t('configure.enable_premod_links_text')}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
<Wordlist
|
||||
bannedWords={settings.wordlist.banned}
|
||||
suspectWords={settings.wordlist.suspect}
|
||||
onChangeWordlist={onChangeWordlist} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
render() {
|
||||
const {settings, data, root} = this.props;
|
||||
|
||||
return (
|
||||
<ConfigurePage
|
||||
title={t('configure.moderation_settings')}
|
||||
>
|
||||
<ConfigureCard
|
||||
checked={settings.requireEmailConfirmation}
|
||||
onCheckbox={this.updateEmailConfirmation}
|
||||
title={t('configure.require_email_verification')}
|
||||
>
|
||||
{t('configure.require_email_verification_text')}
|
||||
</ConfigureCard>
|
||||
<ConfigureCard
|
||||
checked={settings.moderation === 'PRE'}
|
||||
onCheckbox={this.updateModeration}
|
||||
title={t('configure.enable_pre_moderation')}
|
||||
>
|
||||
{t('configure.enable_pre_moderation_text')}
|
||||
</ConfigureCard>
|
||||
<ConfigureCard
|
||||
checked={settings.premodLinksEnable}
|
||||
onCheckbox={this.updatePremodLinksEnable}
|
||||
title={t('configure.enable_premod_links')}
|
||||
>
|
||||
{t('configure.enable_premod_links_text')}
|
||||
</ConfigureCard>
|
||||
<Wordlist
|
||||
bannedWords={settings.wordlist.banned}
|
||||
suspectWords={settings.wordlist.suspect}
|
||||
onChangeWordlist={this.updateWordlist} />
|
||||
<Slot
|
||||
fill="adminModerationSettings"
|
||||
data={data}
|
||||
queryData={{root, settings}}
|
||||
/>
|
||||
</ConfigurePage>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ModerationSettings.propTypes = {
|
||||
onChangeWordlist: PropTypes.func.isRequired,
|
||||
settings: PropTypes.shape({
|
||||
moderation: PropTypes.string.isRequired,
|
||||
wordlist: PropTypes.shape({
|
||||
banned: PropTypes.array.isRequired,
|
||||
suspect: PropTypes.array.isRequired
|
||||
})
|
||||
}).isRequired,
|
||||
updateSettings: PropTypes.func.isRequired
|
||||
updatePending: PropTypes.func.isRequired,
|
||||
data: PropTypes.object.isRequired,
|
||||
root: PropTypes.object.isRequired,
|
||||
settings: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
export default ModerationSettings;
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
.configSettingInfoBox {
|
||||
min-height: 100px;
|
||||
margin-bottom: 20px;
|
||||
width: auto;
|
||||
height: auto;
|
||||
text-align: left;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.descriptionBox {
|
||||
margin-top: 15px;
|
||||
|
||||
input {
|
||||
height: 150px;
|
||||
}
|
||||
}
|
||||
|
||||
.configTimeoutSelect {
|
||||
display: inline-block;
|
||||
margin-left: 20px;
|
||||
|
||||
i { /* fix for firefox and react-mdl-selectfield@0.2.0 */
|
||||
padding: 20px 0;
|
||||
vertical-align: top;
|
||||
}
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.inlineTextfield {
|
||||
border-color: #ccc;
|
||||
border-style: solid;
|
||||
border-width: 0px 0px 1px 0px;
|
||||
text-align: center;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
.inlineTextfield:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.charCountTexfield, .editCommentTimeframeTextfield {
|
||||
width: 4em;
|
||||
padding: 0px;
|
||||
}
|
||||
|
||||
.charCountTexfieldEnabled {
|
||||
border-color: #00796b;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import React from 'react';
|
||||
import {SelectField, Option} from 'react-mdl-selectfield';
|
||||
import t from 'coral-framework/services/i18n';
|
||||
import styles from './Configure.css';
|
||||
import {Checkbox, Textfield} from 'react-mdl';
|
||||
import {Card, Icon, TextArea} from 'coral-ui';
|
||||
import styles from './StreamSettings.css';
|
||||
import {Textfield} from 'react-mdl';
|
||||
import {Icon, TextArea} from 'coral-ui';
|
||||
import PropTypes from 'prop-types';
|
||||
import Slot from 'coral-framework/components/Slot';
|
||||
import MarkdownEditor from 'coral-framework/components/MarkdownEditor';
|
||||
import cn from 'classnames';
|
||||
import ConfigurePage from './ConfigurePage';
|
||||
import ConfigureCard from 'coral-framework/components/ConfigureCard';
|
||||
|
||||
const TIMESTAMPS = {
|
||||
weeks: 60 * 60 * 24 * 7,
|
||||
@@ -12,187 +17,6 @@ const TIMESTAMPS = {
|
||||
hours: 60 * 60
|
||||
};
|
||||
|
||||
const updateCharCountEnable = (updateSettings, charCountChecked) => () => {
|
||||
const charCountEnable = !charCountChecked;
|
||||
updateSettings({charCountEnable});
|
||||
};
|
||||
|
||||
const updateCharCount = (updateSettings, settingsError) => (event) => {
|
||||
const charCount = event.target.value;
|
||||
if (charCount.match(/[^0-9]/) || charCount.length === 0) {
|
||||
settingsError('charCount', true);
|
||||
} else {
|
||||
settingsError('charCount', false);
|
||||
}
|
||||
updateSettings({charCount: charCount});
|
||||
};
|
||||
|
||||
const updateInfoBoxEnable = (updateSettings, infoBox) => () => {
|
||||
const infoBoxEnable = !infoBox;
|
||||
updateSettings({infoBoxEnable});
|
||||
};
|
||||
|
||||
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});
|
||||
};
|
||||
|
||||
// If we are changing the measure we need to recalculate using the old amount
|
||||
// Same thing if we are just changing the amount
|
||||
const updateClosedTimeout = (updateSettings, ts, isMeasure) => (event) => {
|
||||
if (isMeasure) {
|
||||
const amount = getTimeoutAmount(ts);
|
||||
const closedTimeout = amount * TIMESTAMPS[event];
|
||||
updateSettings({closedTimeout});
|
||||
} else {
|
||||
const val = event.target.value;
|
||||
const measure = getTimeoutMeasure(ts);
|
||||
const closedTimeout = val * TIMESTAMPS[measure];
|
||||
updateSettings({closedTimeout});
|
||||
}
|
||||
};
|
||||
|
||||
const updateEditCommentWindowLength = (updateSettings) => (e) => {
|
||||
const value = e.target.value;
|
||||
const valueAsNumber = parseFloat(value);
|
||||
const milliseconds = (!isNaN(valueAsNumber)) && (valueAsNumber * 1000);
|
||||
updateSettings({editCommentWindowLength: milliseconds || value});
|
||||
};
|
||||
|
||||
const StreamSettings = ({updateSettings, settingsError, settings, errors}) => {
|
||||
|
||||
// just putting this here for shorthand below
|
||||
const on = styles.enabledSetting;
|
||||
const off = styles.disabledSetting;
|
||||
|
||||
return (
|
||||
<div className={styles.Configure}>
|
||||
<Card className={`${styles.configSetting} ${settings.charCountEnable ? on : off}`}>
|
||||
<div className={styles.action}>
|
||||
<Checkbox
|
||||
onChange={updateCharCountEnable(updateSettings, settings.charCountEnable)}
|
||||
checked={settings.charCountEnable} />
|
||||
</div>
|
||||
<div className={styles.content}>
|
||||
<div className={styles.settingsHeader}>{t('configure.comment_count_header')}</div>
|
||||
<p className={settings.charCountEnable ? '' : styles.disabledSettingText}>
|
||||
<span>{t('configure.comment_count_text_pre')}</span>
|
||||
<input type='text'
|
||||
className={`${styles.inlineTextfield} ${styles.charCountTexfield} ${settings.charCountEnable && styles.charCountTexfieldEnabled}`}
|
||||
htmlFor='charCount'
|
||||
onChange={updateCharCount(updateSettings, settingsError)}
|
||||
value={settings.charCount}
|
||||
disabled={settings.charCountEnable ? '' : 'disabled'}
|
||||
/>
|
||||
<span>{t('configure.comment_count_text_post')}</span>
|
||||
{
|
||||
errors.charCount &&
|
||||
<span className={styles.settingsError}>
|
||||
<br/>
|
||||
<Icon name="error_outline"/>
|
||||
{t('configure.comment_count_error')}
|
||||
</span>
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
<Card className={`${styles.configSetting} ${styles.configSettingInfoBox} ${settings.infoBoxEnable ? on : off}`}>
|
||||
<div className={styles.action}>
|
||||
<Checkbox
|
||||
onChange={updateInfoBoxEnable(updateSettings, settings.infoBoxEnable)}
|
||||
checked={settings.infoBoxEnable} />
|
||||
</div>
|
||||
<div className={styles.content}>
|
||||
<div className={styles.settingsHeader}>
|
||||
{t('configure.include_comment_stream')}
|
||||
</div>
|
||||
<p className={settings.infoBoxEnable ? '' : styles.disabledSettingText}>
|
||||
{t('configure.include_comment_stream_desc')}
|
||||
</p>
|
||||
<div className={`${styles.configSettingInfoBox} ${settings.infoBoxEnable ? null : styles.hidden}`} >
|
||||
<MarkdownEditor
|
||||
className={styles.descriptionBox}
|
||||
onChange={updateInfoBoxContent(updateSettings)}
|
||||
value={settings.infoBoxContent}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card className={`${styles.configSetting} ${styles.configSettingInfoBox}`}>
|
||||
<div className={styles.wrapper}>
|
||||
<div className={styles.settingsHeader}>{t('configure.closed_stream_settings')}</div>
|
||||
<p>{t('configure.closed_comments_desc')}</p>
|
||||
<div>
|
||||
<TextArea className={styles.descriptionBox}
|
||||
onChange={updateClosedMessage(updateSettings)}
|
||||
value={settings.closedMessage}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
{/* Edit Comment Timeframe */}
|
||||
<Card className={styles.configSetting}>
|
||||
<div className={styles.settingsHeader}>{t('configure.edit_comment_timeframe_heading')}</div>
|
||||
<p>
|
||||
{t('configure.edit_comment_timeframe_text_pre')}
|
||||
|
||||
<input
|
||||
className={`${styles.inlineTextfield} ${styles.editCommentTimeframeTextfield}`}
|
||||
type="number"
|
||||
min="0"
|
||||
onChange={updateEditCommentWindowLength(updateSettings)}
|
||||
placeholder="30"
|
||||
defaultValue={(settings.editCommentWindowLength / 1000) /* saved as ms, rendered as seconds */}
|
||||
pattern='[0-9]+([\.][0-9]*)?'
|
||||
/>
|
||||
|
||||
{t('configure.edit_comment_timeframe_text_post')}
|
||||
</p>
|
||||
</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}>
|
||||
<div className={styles.settingsHeader}>{t('configure.close_after')}</div>
|
||||
<br />
|
||||
<Textfield
|
||||
type='number'
|
||||
pattern='[0-9]+'
|
||||
style={{width: 50}}
|
||||
onChange={updateClosedTimeout(updateSettings, settings.closedTimeout)}
|
||||
value={getTimeoutAmount(settings.closedTimeout)}
|
||||
label={t('configure.closed_comments_label')} />
|
||||
<div className={styles.configTimeoutSelect}>
|
||||
<SelectField
|
||||
label="comments closed time window"
|
||||
value={getTimeoutMeasure(settings.closedTimeout)}
|
||||
onChange={updateClosedTimeout(updateSettings, settings.closedTimeout, true)}>
|
||||
<Option value={'hours'}>{t('configure.hours')}</Option>
|
||||
<Option value={'days'}>{t('configure.days')}</Option>
|
||||
<Option value={'weeks'}>{t('configure.weeks')}</Option>
|
||||
</SelectField>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
{/* the above card should be the last one if at all possible because of z-index issues with the selects */}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default StreamSettings;
|
||||
|
||||
// To see if we are talking about weeks, days or hours
|
||||
// We talk the remainder of the division and see if it's 0
|
||||
const getTimeoutMeasure = (ts) => {
|
||||
@@ -208,3 +32,190 @@ const getTimeoutMeasure = (ts) => {
|
||||
// Dividing the amount by it's measure (hours, days, weeks) we
|
||||
// obtain the amount of time
|
||||
const getTimeoutAmount = (ts) => ts / TIMESTAMPS[getTimeoutMeasure(ts)];
|
||||
|
||||
class StreamSettings extends React.Component {
|
||||
|
||||
updateCharCountEnable = () => {
|
||||
const updater = {charCountEnable: {$set: !this.props.settings.charCountEnable}};
|
||||
this.props.updatePending({updater});
|
||||
};
|
||||
|
||||
updateCharCount = (event) => {
|
||||
let error = null;
|
||||
const charCount = event.target.value;
|
||||
if (charCount.match(/[^0-9]/) || charCount.length === 0) {
|
||||
error = true;
|
||||
}
|
||||
|
||||
const updater = {charCount: {$set: charCount}};
|
||||
const errorUpdater = {charCount: {$set: error}};
|
||||
|
||||
this.props.updatePending({updater, errorUpdater});
|
||||
};
|
||||
|
||||
updateInfoBoxEnable = () => {
|
||||
const updater = {infoBoxEnable: {$set: !this.props.settings.infoBoxEnable}};
|
||||
this.props.updatePending({updater});
|
||||
};
|
||||
|
||||
updateInfoBoxContent = (value) => {
|
||||
const updater = {infoBoxContent: {$set: value}};
|
||||
this.props.updatePending({updater});
|
||||
};
|
||||
|
||||
updateClosedMessage = (event) => {
|
||||
const updater = {closedMessage: {$set: event.target.value}};
|
||||
this.props.updatePending({updater});
|
||||
};
|
||||
|
||||
updateEditCommentWindowLength = (e) => {
|
||||
const value = e.target.value;
|
||||
const valueAsNumber = parseFloat(value);
|
||||
const milliseconds = (!isNaN(valueAsNumber)) && (valueAsNumber * 1000);
|
||||
|
||||
const updater = {editCommentWindowLength: {$set: milliseconds || value}};
|
||||
this.props.updatePending({updater});
|
||||
};
|
||||
|
||||
updateAutoClose = () => {
|
||||
const updater = {autoCloseStream: {$set: !this.props.settings.autoCloseStream}};
|
||||
this.props.updatePending({updater});
|
||||
};
|
||||
|
||||
updateClosedTimeout = (event) => {
|
||||
const val = event.target.value;
|
||||
const measure = getTimeoutMeasure(this.props.settings.closedTimeout);
|
||||
|
||||
const updater = {closedTimeout: {$set: val * TIMESTAMPS[measure]}};
|
||||
this.props.updatePending({updater});
|
||||
};
|
||||
|
||||
// If we are changing the measure we need to recalculate using the old amount
|
||||
// Same thing if we are just changing the amount
|
||||
updateClosedTimeoutMeasure = (event) => {
|
||||
const amount = getTimeoutAmount(this.props.settings.closedTimeout);
|
||||
|
||||
const updater = {closedTimeout: {$set: amount * TIMESTAMPS[event]}};
|
||||
this.props.updatePending({updater});
|
||||
};
|
||||
|
||||
render() {
|
||||
const {settings, data, root, errors} = this.props;
|
||||
|
||||
return (
|
||||
<ConfigurePage
|
||||
title={t('configure.stream_settings')}
|
||||
>
|
||||
<ConfigureCard
|
||||
checked={settings.charCountEnable}
|
||||
onCheckbox={this.updateCharCountEnable}
|
||||
title={t('configure.comment_count_header')}
|
||||
>
|
||||
<span>{t('configure.comment_count_text_pre')}</span>
|
||||
<input type='text'
|
||||
className={cn(styles.inlineTextfield, styles.charCountTexfield, settings.charCountEnable && styles.charCountTexfieldEnable)}
|
||||
htmlFor='charCount'
|
||||
onChange={this.updateCharCount}
|
||||
value={settings.charCount}
|
||||
disabled={settings.charCountEnable ? '' : 'disabled'}
|
||||
/>
|
||||
<span>{t('configure.comment_count_text_post')}</span>
|
||||
{
|
||||
errors.charCount &&
|
||||
<span className={styles.settingsError}>
|
||||
<br/>
|
||||
<Icon name="error_outline"/>
|
||||
{t('configure.comment_count_error')}
|
||||
</span>
|
||||
}
|
||||
</ConfigureCard>
|
||||
<ConfigureCard
|
||||
checked={settings.infoBoxEnable}
|
||||
onCheckbox={this.updateInfoBoxEnable}
|
||||
title={t('configure.include_comment_stream')}
|
||||
>
|
||||
<p>
|
||||
{t('configure.include_comment_stream_desc')}
|
||||
</p>
|
||||
<div className={cn(styles.configSettingInfoBox, settings.infoBoxEnable ? null : styles.hidden)} >
|
||||
<MarkdownEditor
|
||||
className={styles.descriptionBox}
|
||||
onChange={this.updateInfoBoxContent}
|
||||
value={settings.infoBoxContent}
|
||||
/>
|
||||
</div>
|
||||
</ConfigureCard>
|
||||
<ConfigureCard
|
||||
checked={settings.configSettingInfoBox}
|
||||
onCheckbox={this.updateClosedMessage}
|
||||
title={t('configure.closed_stream_settings')}
|
||||
>
|
||||
<p>{t('configure.closed_comments_desc')}</p>
|
||||
<div>
|
||||
<TextArea className={styles.descriptionBox}
|
||||
onChange={this.updateClosedMessage}
|
||||
value={settings.closedMessage}
|
||||
/>
|
||||
</div>
|
||||
</ConfigureCard>
|
||||
<ConfigureCard
|
||||
title={t('configure.edit_comment_timeframe_heading')}
|
||||
>
|
||||
{t('configure.edit_comment_timeframe_text_pre')}
|
||||
|
||||
<input
|
||||
className={cn(styles.inlineTextfield, styles.editCommentTimeframeTextfield)}
|
||||
type="number"
|
||||
min="0"
|
||||
onChange={this.updateEditCommentWindowLength}
|
||||
placeholder="30"
|
||||
defaultValue={(settings.editCommentWindowLength / 1000) /* saved as ms, rendered as seconds */}
|
||||
pattern='[0-9]+([\.][0-9]*)?'
|
||||
/>
|
||||
|
||||
{t('configure.edit_comment_timeframe_text_post')}
|
||||
</ConfigureCard>
|
||||
<ConfigureCard
|
||||
checked={settings.autoCloseStream}
|
||||
onCheckbox={this.updateAutoClose}
|
||||
title={t('configure.close_after')}
|
||||
>
|
||||
<Textfield
|
||||
type='number'
|
||||
pattern='[0-9]+'
|
||||
style={{width: 50}}
|
||||
onChange={this.updateClosedTimeout}
|
||||
value={getTimeoutAmount(settings.closedTimeout)}
|
||||
label={t('configure.closed_comments_label')} />
|
||||
<div className={styles.configTimeoutSelect}>
|
||||
<SelectField
|
||||
label="comments closed time window"
|
||||
value={getTimeoutMeasure(settings.closedTimeout)}
|
||||
onChange={this.updateClosedTimeoutMeasure}>
|
||||
<Option value={'hours'}>{t('configure.hours')}</Option>
|
||||
<Option value={'days'}>{t('configure.days')}</Option>
|
||||
<Option value={'weeks'}>{t('configure.weeks')}</Option>
|
||||
</SelectField>
|
||||
</div>
|
||||
</ConfigureCard>
|
||||
{/* the above card should be the last one if at all possible because of z-index issues with the selects */}
|
||||
<Slot
|
||||
fill="adminStreamSettings"
|
||||
data={data}
|
||||
queryData={{root, settings}}
|
||||
/>
|
||||
</ConfigurePage>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
StreamSettings.propTypes = {
|
||||
updatePending: PropTypes.func.isRequired,
|
||||
errors: PropTypes.object.isRequired,
|
||||
data: PropTypes.object.isRequired,
|
||||
root: PropTypes.object.isRequired,
|
||||
settings: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
export default StreamSettings;
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
.customCSSInput {
|
||||
width: 100%;
|
||||
font-size: 14px;
|
||||
padding: 14px;
|
||||
letter-spacing: 0.03em;
|
||||
color: #555;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,44 +1,67 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {Card} from 'coral-ui';
|
||||
import Domainlist from './Domainlist';
|
||||
import EmbedLink from './EmbedLink';
|
||||
import styles from './Configure.css';
|
||||
import styles from './TechSettings.css';
|
||||
import Slot from 'coral-framework/components/Slot';
|
||||
import t from 'coral-framework/services/i18n';
|
||||
import ConfigurePage from './ConfigurePage';
|
||||
import ConfigureCard from 'coral-framework/components/ConfigureCard';
|
||||
|
||||
const updateCustomCssUrl = (updateSettings) => (event) => {
|
||||
const customCssUrl = event.target.value;
|
||||
updateSettings({customCssUrl});
|
||||
};
|
||||
class TechSettings extends React.Component {
|
||||
|
||||
const TechSettings = ({settings, onChangeDomainlist, updateSettings}) => {
|
||||
return (
|
||||
<div className={styles.Configure}>
|
||||
<Domainlist
|
||||
domains={settings.domains.whitelist}
|
||||
onChangeDomainlist={onChangeDomainlist} />
|
||||
<EmbedLink />
|
||||
<Card className={styles.configSetting}>
|
||||
<div className={styles.wrapper}>
|
||||
<div className={styles.settingsHeader}>{t('configure.custom_css_url')}</div>
|
||||
updateCustomCssUrl = (event) => {
|
||||
const updater = {customCssUrl: {$set: event.target.value}};
|
||||
this.props.updatePending({updater});
|
||||
};
|
||||
|
||||
updateDomainlist = (listName, list) => {
|
||||
this.props.updatePending({updater: {
|
||||
domains: {$apply: (domains) => {
|
||||
const changeSet = {[listName]: list};
|
||||
if (!domains) {
|
||||
return changeSet;
|
||||
}
|
||||
return {
|
||||
...domains,
|
||||
...changeSet,
|
||||
};
|
||||
}},
|
||||
}});
|
||||
};
|
||||
|
||||
render() {
|
||||
const {settings, data, root} = this.props;
|
||||
return (
|
||||
<ConfigurePage
|
||||
title={t('configure.tech_settings')}
|
||||
>
|
||||
<Domainlist
|
||||
domains={settings.domains.whitelist}
|
||||
onChangeDomainlist={this.updateDomainlist} />
|
||||
<EmbedLink />
|
||||
<ConfigureCard title={t('configure.custom_css_url')}>
|
||||
<p>{t('configure.custom_css_url_desc')}</p>
|
||||
<input
|
||||
className={styles.customCSSInput}
|
||||
value={settings.customCssUrl}
|
||||
onChange={updateCustomCssUrl(updateSettings)} />
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
onChange={this.updateCustomCssUrl} />
|
||||
</ConfigureCard>
|
||||
<Slot
|
||||
fill="adminTechSettings"
|
||||
data={data}
|
||||
queryData={{root, settings}}
|
||||
/>
|
||||
</ConfigurePage>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
TechSettings.propTypes = {
|
||||
settings: PropTypes.shape({
|
||||
domains: PropTypes.shape({
|
||||
whitelist: PropTypes.array.isRequired
|
||||
})
|
||||
}).isRequired,
|
||||
updateSettings: PropTypes.func.isRequired
|
||||
updatePending: PropTypes.func.isRequired,
|
||||
data: PropTypes.object.isRequired,
|
||||
root: PropTypes.object.isRequired,
|
||||
settings: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
export default TechSettings;
|
||||
|
||||
@@ -1,33 +1,33 @@
|
||||
import React from 'react';
|
||||
import t from 'coral-framework/services/i18n';
|
||||
import TagsInput from 'coral-admin/src/components/TagsInput';
|
||||
import styles from './Configure.css';
|
||||
import {Card} from 'coral-ui';
|
||||
import PropTypes from 'prop-types';
|
||||
import ConfigureCard from 'coral-framework/components/ConfigureCard';
|
||||
|
||||
const Wordlist = ({suspectWords, bannedWords, onChangeWordlist}) => (
|
||||
<div>
|
||||
<Card id={styles.bannedWordlist} className={styles.configSetting}>
|
||||
<div className={styles.settingsHeader}>{t('configure.banned_words_title')}</div>
|
||||
<p className={styles.wordlistDesc}>{t('configure.banned_word_text')}</p>
|
||||
<div className={styles.wrapper}>
|
||||
<TagsInput
|
||||
value={bannedWords}
|
||||
inputProps={{placeholder: 'word or phrase'}}
|
||||
onChange={(tags) => onChangeWordlist('banned', tags)}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
<Card id={styles.suspectWordlist} className={styles.configSetting}>
|
||||
<div className={styles.settingsHeader}>{t('configure.suspect_word_title')}</div>
|
||||
<p className={styles.wordlistDesc}>{t('configure.suspect_word_text')}</p>
|
||||
<div className={styles.wrapper}>
|
||||
<TagsInput
|
||||
value={suspectWords}
|
||||
inputProps={{placeholder: 'word or phrase'}}
|
||||
onChange={(tags) => onChangeWordlist('suspect', tags)} />
|
||||
</div>
|
||||
</Card>
|
||||
<ConfigureCard title={t('configure.banned_words_title')}>
|
||||
<p>{t('configure.banned_word_text')}</p>
|
||||
<TagsInput
|
||||
value={bannedWords}
|
||||
inputProps={{placeholder: 'word or phrase'}}
|
||||
onChange={(tags) => onChangeWordlist('banned', tags)}
|
||||
/>
|
||||
</ConfigureCard>
|
||||
<ConfigureCard title={t('configure.suspect_word_title')}>
|
||||
<p>{t('configure.suspect_word_text')}</p>
|
||||
<TagsInput
|
||||
value={suspectWords}
|
||||
inputProps={{placeholder: 'word or phrase'}}
|
||||
onChange={(tags) => onChangeWordlist('suspect', tags)} />
|
||||
</ConfigureCard>
|
||||
</div>
|
||||
);
|
||||
|
||||
Wordlist.propTypes = {
|
||||
suspectWords: PropTypes.array.isRequired,
|
||||
bannedWords: PropTypes.array.isRequired,
|
||||
onChangeWordlist: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default Wordlist;
|
||||
|
||||
@@ -1,42 +1,128 @@
|
||||
import React, {Component} from 'react';
|
||||
import {connect} from 'react-redux';
|
||||
import {bindActionCreators} from 'redux';
|
||||
import {compose} from 'react-apollo';
|
||||
import {
|
||||
fetchSettings,
|
||||
updateSettings,
|
||||
saveSettingsToServer,
|
||||
updateWordlist,
|
||||
updateDomainlist
|
||||
} from '../../../actions/settings';
|
||||
import {compose, gql} from 'react-apollo';
|
||||
import withQuery from 'coral-framework/hocs/withQuery';
|
||||
import {Spinner} from 'coral-ui';
|
||||
import {notify} from 'coral-framework/actions/notification';
|
||||
import PropTypes from 'prop-types';
|
||||
import assignWith from 'lodash/assignWith';
|
||||
import {withUpdateSettings} from 'coral-framework/graphql/mutations';
|
||||
import {getErrorMessages, getDefinitionName} from 'coral-framework/utils';
|
||||
import StreamSettings from './StreamSettings';
|
||||
import TechSettings from './TechSettings';
|
||||
import ModerationSettings from './ModerationSettings';
|
||||
import {clearPending, setActiveSection} from '../../../actions/configure';
|
||||
|
||||
import Configure from '../components/Configure';
|
||||
|
||||
// Like lodash merge but does not recurse into arrays.
|
||||
const mergeExcludingArrays = (objValue, srcValue) => {
|
||||
if (typeof srcValue === 'object' && !Array.isArray(srcValue)) {
|
||||
return assignWith({}, objValue, srcValue, mergeExcludingArrays);
|
||||
}
|
||||
return srcValue;
|
||||
};
|
||||
|
||||
class ConfigureContainer extends Component {
|
||||
componentWillMount = () => {
|
||||
this.props.fetchSettings();
|
||||
|
||||
// Merge current settings with pending settings.
|
||||
getMergedSettings = (props = this.props) => {
|
||||
return assignWith({}, props.root.settings, props.pending, mergeExcludingArrays);
|
||||
}
|
||||
|
||||
// Cached merged settings.
|
||||
mergedSettings = this.getMergedSettings();
|
||||
|
||||
savePending = async () => {
|
||||
try {
|
||||
await this.props.updateSettings(this.props.pending);
|
||||
this.props.clearPending();
|
||||
}
|
||||
catch(err) {
|
||||
this.props.notify('error', getErrorMessages(err));
|
||||
}
|
||||
};
|
||||
|
||||
componentWillReceiveProps(nextProps) {
|
||||
|
||||
// Recalculate merged settings when necessary.
|
||||
if (this.props.root.settings !== nextProps.root.settings || this.props.pending !== nextProps.pending) {
|
||||
this.mergedSettings = this.getMergedSettings(nextProps);
|
||||
}
|
||||
}
|
||||
|
||||
render () {
|
||||
return <Configure {...this.props} />;
|
||||
if(this.props.data.loading) {
|
||||
return <Spinner/>;
|
||||
}
|
||||
|
||||
return <Configure
|
||||
notify={this.props.notify}
|
||||
auth={this.props.auth}
|
||||
data={this.props.data}
|
||||
root={this.props.root}
|
||||
settings={this.mergedSettings}
|
||||
canSave={this.props.canSave}
|
||||
savePending={this.savePending}
|
||||
setActiveSection={this.props.setActiveSection}
|
||||
activeSection={this.props.activeSection}
|
||||
/>;
|
||||
}
|
||||
}
|
||||
|
||||
const withConfigureQuery = withQuery(gql`
|
||||
query TalkAdmin_Configure {
|
||||
settings {
|
||||
...${getDefinitionName(StreamSettings.fragments.settings)}
|
||||
...${getDefinitionName(TechSettings.fragments.settings)}
|
||||
...${getDefinitionName(ModerationSettings.fragments.settings)}
|
||||
}
|
||||
...${getDefinitionName(StreamSettings.fragments.root)}
|
||||
...${getDefinitionName(TechSettings.fragments.root)}
|
||||
...${getDefinitionName(ModerationSettings.fragments.root)}
|
||||
}
|
||||
${StreamSettings.fragments.root}
|
||||
${StreamSettings.fragments.settings}
|
||||
${TechSettings.fragments.root}
|
||||
${TechSettings.fragments.settings}
|
||||
${ModerationSettings.fragments.root}
|
||||
${ModerationSettings.fragments.settings}
|
||||
`, {
|
||||
options: () => ({
|
||||
variables: {},
|
||||
}),
|
||||
});
|
||||
|
||||
const mapStateToProps = (state) => ({
|
||||
auth: state.auth,
|
||||
settings: state.settings
|
||||
pending: state.configure.pending,
|
||||
canSave: state.configure.canSave,
|
||||
activeSection: state.configure.activeSection,
|
||||
});
|
||||
|
||||
const mapDispatchToProps = (dispatch) =>
|
||||
bindActionCreators({
|
||||
fetchSettings,
|
||||
updateSettings,
|
||||
saveSettingsToServer,
|
||||
updateWordlist,
|
||||
updateDomainlist
|
||||
notify,
|
||||
clearPending,
|
||||
setActiveSection,
|
||||
}, dispatch);
|
||||
|
||||
export default compose(
|
||||
withUpdateSettings,
|
||||
withConfigureQuery,
|
||||
connect(mapStateToProps, mapDispatchToProps),
|
||||
)(ConfigureContainer);
|
||||
|
||||
ConfigureContainer.propTypes = {
|
||||
updateSettings: PropTypes.func.isRequired,
|
||||
clearPending: PropTypes.func.isRequired,
|
||||
setActiveSection: PropTypes.func.isRequired,
|
||||
notify: PropTypes.func.isRequired,
|
||||
auth: PropTypes.object.isRequired,
|
||||
data: PropTypes.object.isRequired,
|
||||
root: PropTypes.object.isRequired,
|
||||
canSave: PropTypes.bool.isRequired,
|
||||
pending: PropTypes.object.isRequired,
|
||||
activeSection: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import {connect} from 'react-redux';
|
||||
import {bindActionCreators} from 'redux';
|
||||
import {compose, gql} from 'react-apollo';
|
||||
import ModerationSettings from '../components/ModerationSettings';
|
||||
import withFragments from 'coral-framework/hocs/withFragments';
|
||||
import {getSlotFragmentSpreads} from 'coral-framework/utils';
|
||||
import {updatePending} from '../../../actions/configure';
|
||||
|
||||
const slots = [
|
||||
'adminModerationSettings',
|
||||
];
|
||||
|
||||
const mapDispatchToProps = (dispatch) =>
|
||||
bindActionCreators({
|
||||
updatePending,
|
||||
}, dispatch);
|
||||
|
||||
export default compose(
|
||||
withFragments({
|
||||
root: gql`
|
||||
fragment TalkAdmin_ModerationSettings_root on RootQuery {
|
||||
__typename
|
||||
${getSlotFragmentSpreads(slots, 'root')}
|
||||
}
|
||||
`,
|
||||
settings: gql`
|
||||
fragment TalkAdmin_ModerationSettings_settings on Settings {
|
||||
requireEmailConfirmation
|
||||
moderation
|
||||
premodLinksEnable
|
||||
wordlist {
|
||||
suspect
|
||||
banned
|
||||
}
|
||||
${getSlotFragmentSpreads(slots, 'settings')}
|
||||
}
|
||||
`
|
||||
}),
|
||||
connect(null, mapDispatchToProps),
|
||||
)(ModerationSettings);
|
||||
@@ -0,0 +1,45 @@
|
||||
import {connect} from 'react-redux';
|
||||
import {bindActionCreators} from 'redux';
|
||||
import {compose, gql} from 'react-apollo';
|
||||
import StreamSettings from '../components/StreamSettings';
|
||||
import withFragments from 'coral-framework/hocs/withFragments';
|
||||
import {getSlotFragmentSpreads} from 'coral-framework/utils';
|
||||
import {updatePending} from '../../../actions/configure';
|
||||
|
||||
const slots = [
|
||||
'adminStreamSettings',
|
||||
];
|
||||
|
||||
const mapStateToProps = (state) => ({
|
||||
errors: state.configure.errors,
|
||||
});
|
||||
|
||||
const mapDispatchToProps = (dispatch) =>
|
||||
bindActionCreators({
|
||||
updatePending,
|
||||
}, dispatch);
|
||||
|
||||
export default compose(
|
||||
withFragments({
|
||||
root: gql`
|
||||
fragment TalkAdmin_StreamSettings_root on RootQuery {
|
||||
__typename
|
||||
${getSlotFragmentSpreads(slots, 'root')}
|
||||
}
|
||||
`,
|
||||
settings: gql`
|
||||
fragment TalkAdmin_StreamSettings_settings on Settings {
|
||||
infoBoxEnable
|
||||
charCount
|
||||
charCountEnable
|
||||
infoBoxContent
|
||||
editCommentWindowLength
|
||||
autoCloseStream
|
||||
closedTimeout
|
||||
closedMessage
|
||||
${getSlotFragmentSpreads(slots, 'settings')}
|
||||
}
|
||||
`
|
||||
}),
|
||||
connect(mapStateToProps, mapDispatchToProps),
|
||||
)(StreamSettings);
|
||||
@@ -0,0 +1,37 @@
|
||||
import {connect} from 'react-redux';
|
||||
import {bindActionCreators} from 'redux';
|
||||
import {compose, gql} from 'react-apollo';
|
||||
import TechSettings from '../components/TechSettings';
|
||||
import withFragments from 'coral-framework/hocs/withFragments';
|
||||
import {getSlotFragmentSpreads} from 'coral-framework/utils';
|
||||
import {updatePending} from '../../../actions/configure';
|
||||
|
||||
const slots = [
|
||||
'adminTechSettings',
|
||||
];
|
||||
|
||||
const mapDispatchToProps = (dispatch) =>
|
||||
bindActionCreators({
|
||||
updatePending,
|
||||
}, dispatch);
|
||||
|
||||
export default compose(
|
||||
withFragments({
|
||||
root: gql`
|
||||
fragment TalkAdmin_TechSettings_root on RootQuery {
|
||||
__typename
|
||||
${getSlotFragmentSpreads(slots, 'root')}
|
||||
}
|
||||
`,
|
||||
settings: gql`
|
||||
fragment TalkAdmin_TechSettings_settings on Settings {
|
||||
customCssUrl
|
||||
domains {
|
||||
whitelist
|
||||
}
|
||||
${getSlotFragmentSpreads(slots, 'settings')}
|
||||
}
|
||||
`
|
||||
}),
|
||||
connect(null, mapDispatchToProps),
|
||||
)(TechSettings);
|
||||
@@ -42,11 +42,11 @@ export const witDashboardQuery = withQuery(gql`
|
||||
}
|
||||
}
|
||||
`, {
|
||||
options: ({settings: {dashboardWindowStart, dashboardWindowEnd}}) => {
|
||||
options: ({windowStart, windowEnd}) => {
|
||||
return {
|
||||
variables: {
|
||||
from: dashboardWindowStart,
|
||||
to: dashboardWindowEnd
|
||||
from: windowStart,
|
||||
to: windowEnd,
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -54,8 +54,8 @@ export const witDashboardQuery = withQuery(gql`
|
||||
|
||||
const mapStateToProps = (state) => {
|
||||
return {
|
||||
settings: state.settings,
|
||||
moderation: state.moderation
|
||||
windowStart: state.dashboard.windowStart,
|
||||
windowEnd: state.dashboard.windowEnd,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -58,12 +58,11 @@ class Comment extends React.Component {
|
||||
render() {
|
||||
const {
|
||||
comment,
|
||||
suspectWords,
|
||||
bannedWords,
|
||||
selected,
|
||||
className,
|
||||
data,
|
||||
root,
|
||||
root: {settings},
|
||||
currentUserId,
|
||||
currentAsset,
|
||||
} = this.props;
|
||||
@@ -130,8 +129,8 @@ class Comment extends React.Component {
|
||||
<div className={styles.itemBody}>
|
||||
<div className={styles.body}>
|
||||
<CommentBodyHighlighter
|
||||
suspectWords={suspectWords}
|
||||
bannedWords={bannedWords}
|
||||
suspectWords={settings.wordlist.suspect}
|
||||
bannedWords={settings.wordlist.banned}
|
||||
body={comment.body}
|
||||
/>
|
||||
{' '}
|
||||
@@ -188,8 +187,6 @@ Comment.propTypes = {
|
||||
acceptComment: PropTypes.func.isRequired,
|
||||
rejectComment: PropTypes.func.isRequired,
|
||||
className: PropTypes.string,
|
||||
suspectWords: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||
bannedWords: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||
currentAsset: PropTypes.object,
|
||||
showBanUserDialog: PropTypes.func.isRequired,
|
||||
showSuspendUserDialog: PropTypes.func.isRequired,
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
@custom-media --desktop (max-width: 1280px);
|
||||
|
||||
.container {
|
||||
max-width: 1280px;
|
||||
position: relative;
|
||||
margin: 0 auto;
|
||||
|
||||
@media (--desktop) {
|
||||
padding-right: 20px;
|
||||
padding-left: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import React, {Component} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import key from 'keymaster';
|
||||
|
||||
import cn from 'classnames';
|
||||
import styles from './Moderation.css';
|
||||
import ModerationQueue from './ModerationQueue';
|
||||
import ModerationMenu from './ModerationMenu';
|
||||
import ModerationHeader from './ModerationHeader';
|
||||
import ModerationKeysModal from '../../../components/ModerationKeysModal';
|
||||
import StorySearch from '../containers/StorySearch';
|
||||
import Slot from 'coral-framework/components/Slot';
|
||||
import ViewOptions from './ViewOptions';
|
||||
|
||||
class Moderation extends Component {
|
||||
constructor(props) {
|
||||
@@ -75,7 +77,7 @@ class Moderation extends Component {
|
||||
const comments = this.getComments();
|
||||
const commentIdx = comments.findIndex((comment) => comment.id === selectedCommentId);
|
||||
const comment = comments[commentIdx];
|
||||
|
||||
|
||||
if (accept) {
|
||||
comment.status !== 'ACCEPTED' && acceptComment({commentId: comment.id});
|
||||
} else {
|
||||
@@ -218,7 +220,7 @@ class Moderation extends Component {
|
||||
}
|
||||
|
||||
render () {
|
||||
const {root, data, moderation, settings, viewUserDetail, activeTab, getModPath, queueConfig, handleCommentChange, ...props} = this.props;
|
||||
const {root, data, moderation, viewUserDetail, activeTab, getModPath, queueConfig, handleCommentChange, ...props} = this.props;
|
||||
const {asset} = root;
|
||||
const assetId = asset && asset.id;
|
||||
|
||||
@@ -244,45 +246,44 @@ class Moderation extends Component {
|
||||
asset={asset}
|
||||
getModPath={getModPath}
|
||||
items={menuItems}
|
||||
selectSort={this.props.setSortOrder}
|
||||
sort={this.props.moderation.sortOrder}
|
||||
activeTab={activeTab}
|
||||
/>
|
||||
<ModerationQueue
|
||||
key={`${activeTab}_${this.props.moderation.sortOrder}`}
|
||||
data={this.props.data}
|
||||
root={this.props.root}
|
||||
currentAsset={asset}
|
||||
comments={comments.nodes}
|
||||
activeTab={activeTab}
|
||||
singleView={moderation.singleView}
|
||||
selectedCommentId={this.state.selectedCommentId}
|
||||
bannedWords={settings.wordlist.banned}
|
||||
suspectWords={settings.wordlist.suspect}
|
||||
showBanUserDialog={props.showBanUserDialog}
|
||||
showSuspendUserDialog={props.showSuspendUserDialog}
|
||||
acceptComment={props.acceptComment}
|
||||
rejectComment={props.rejectComment}
|
||||
loadMore={this.loadMore}
|
||||
assetId={assetId}
|
||||
sort={this.props.moderation.sortOrder}
|
||||
commentCount={activeTabCount}
|
||||
currentUserId={this.props.auth.user.id}
|
||||
viewUserDetail={viewUserDetail}
|
||||
/>
|
||||
<ModerationKeysModal
|
||||
hideShortcutsNote={props.hideShortcutsNote}
|
||||
shortcutsNoteVisible={moderation.shortcutsNoteVisible}
|
||||
open={moderation.modalOpen}
|
||||
onClose={this.onClose}/>
|
||||
|
||||
<div className={cn(styles.container, 'talk-admin-moderation-container')}>
|
||||
<ViewOptions
|
||||
selectSort={this.props.setSortOrder}
|
||||
sort={this.props.moderation.sortOrder}
|
||||
/>
|
||||
<ModerationQueue
|
||||
key={`${activeTab}_${this.props.moderation.sortOrder}`}
|
||||
data={this.props.data}
|
||||
root={this.props.root}
|
||||
currentAsset={asset}
|
||||
comments={comments.nodes}
|
||||
activeTab={activeTab}
|
||||
singleView={moderation.singleView}
|
||||
selectedCommentId={this.state.selectedCommentId}
|
||||
showBanUserDialog={props.showBanUserDialog}
|
||||
showSuspendUserDialog={props.showSuspendUserDialog}
|
||||
acceptComment={props.acceptComment}
|
||||
rejectComment={props.rejectComment}
|
||||
loadMore={this.loadMore}
|
||||
commentCount={activeTabCount}
|
||||
currentUserId={this.props.auth.user.id}
|
||||
viewUserDetail={viewUserDetail}
|
||||
/>
|
||||
<ModerationKeysModal
|
||||
hideShortcutsNote={props.hideShortcutsNote}
|
||||
shortcutsNoteVisible={moderation.shortcutsNoteVisible}
|
||||
open={moderation.modalOpen}
|
||||
onClose={this.onClose}
|
||||
/>
|
||||
</div>
|
||||
<StorySearch
|
||||
assetId={assetId}
|
||||
moderation={this.props.moderation}
|
||||
closeSearch={this.closeSearch}
|
||||
storySearchChange={this.props.storySearchChange}
|
||||
/>
|
||||
|
||||
<Slot
|
||||
data={data}
|
||||
queryData={{root, asset}}
|
||||
@@ -303,7 +304,6 @@ Moderation.propTypes = {
|
||||
storySearchChange: PropTypes.func.isRequired,
|
||||
moderation: PropTypes.object.isRequired,
|
||||
auth: PropTypes.object.isRequired,
|
||||
settings: PropTypes.object.isRequired,
|
||||
queueConfig: PropTypes.object.isRequired,
|
||||
handleCommentChange: PropTypes.func.isRequired,
|
||||
setSortOrder: PropTypes.func.isRequired,
|
||||
@@ -316,6 +316,7 @@ Moderation.propTypes = {
|
||||
activeTab: PropTypes.string.isRequired,
|
||||
data: PropTypes.object.isRequired,
|
||||
root: PropTypes.object.isRequired,
|
||||
router: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
export default Moderation;
|
||||
|
||||
@@ -7,12 +7,7 @@
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.tabBarPadding {
|
||||
width: 150px;
|
||||
}
|
||||
|
||||
.tab {
|
||||
flex: 1;
|
||||
color: #BDBDBD;
|
||||
text-transform: capitalize;
|
||||
font-weight: 100;
|
||||
@@ -22,9 +17,9 @@
|
||||
transition: color 200ms;
|
||||
padding: 0px 10px;
|
||||
margin-right: 20px;
|
||||
|
||||
&:hover {
|
||||
color: white;
|
||||
/*border-bottom: solid 2px #F36451;*/
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
@@ -48,55 +43,12 @@
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.selectField {
|
||||
position: relative;
|
||||
width: 140px;
|
||||
height: 36px;
|
||||
top: 5px;
|
||||
margin-right: 10px;
|
||||
background: #FFF;
|
||||
padding: 10px 15px;
|
||||
box-sizing: border-box;
|
||||
border-radius: 2px;
|
||||
bor
|
||||
box-shadow: 0 2px 2px 0 rgba(0,0,0,.14), 0 3px 1px -2px rgba(0,0,0,.2), 0 1px 5px 0 rgba(0,0,0,.12);
|
||||
|
||||
> div {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
i {
|
||||
position: absolute;
|
||||
top: 7px;
|
||||
right: 7px;
|
||||
}
|
||||
|
||||
input {
|
||||
padding: 0;
|
||||
font-size: 13px;
|
||||
letter-spacing: 0.7px;
|
||||
font-weight: 400;
|
||||
border-bottom: 0px;
|
||||
}
|
||||
|
||||
label {
|
||||
top: -4px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.tabIcon {
|
||||
position: relative;
|
||||
top: 3px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
|
||||
@media (--big-viewport) {
|
||||
.tab {
|
||||
flex: none;
|
||||
}
|
||||
}
|
||||
.tabBarContainer {
|
||||
margin: 0 auto;
|
||||
}
|
||||
@@ -2,26 +2,20 @@ import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import CountBadge from '../../../components/CountBadge';
|
||||
import styles from './ModerationMenu.css';
|
||||
import {SelectField, Option} from 'react-mdl-selectfield';
|
||||
import {Icon} from 'coral-ui';
|
||||
import {Link} from 'react-router';
|
||||
import cn from 'classnames';
|
||||
|
||||
import t from 'coral-framework/services/i18n';
|
||||
|
||||
const ModerationMenu = ({
|
||||
asset = {},
|
||||
items,
|
||||
selectSort,
|
||||
sort,
|
||||
getModPath,
|
||||
activeTab
|
||||
}) => {
|
||||
return (
|
||||
<div className="mdl-tabs">
|
||||
<div className={`mdl-tabs__tab-bar ${styles.tabBar}`}>
|
||||
<div className={styles.tabBarPadding} />
|
||||
<div>
|
||||
<div className={cn('mdl-tabs__tab-bar', styles.tabBar, 'talk-admin-moderation-menu-tabbar')}>
|
||||
<div className={cn(styles.tabBarContainer, 'talk-admin-moderation-menu-tabbar-container')}>
|
||||
{items.map((queue) =>
|
||||
<Link
|
||||
key={queue.key}
|
||||
@@ -32,14 +26,6 @@ const ModerationMenu = ({
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
<SelectField
|
||||
className={styles.selectField}
|
||||
label="Sort"
|
||||
value={sort}
|
||||
onChange={(sort) => selectSort(sort)}>
|
||||
<Option value={'DESC'}>{t('modqueue.newest_first')}</Option>
|
||||
<Option value={'ASC'}>{t('modqueue.oldest_first')}</Option>
|
||||
</SelectField>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -50,8 +36,6 @@ ModerationMenu.propTypes = {
|
||||
asset: PropTypes.shape({
|
||||
id: PropTypes.string
|
||||
}),
|
||||
selectSort: PropTypes.func.isRequired,
|
||||
sort: PropTypes.string.isRequired,
|
||||
getModPath: PropTypes.func.isRequired,
|
||||
activeTab: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
@@ -147,8 +147,6 @@ class ModerationQueue extends React.Component {
|
||||
key={comment.id}
|
||||
comment={comment}
|
||||
selected={true}
|
||||
suspectWords={props.suspectWords}
|
||||
bannedWords={props.bannedWords}
|
||||
viewUserDetail={viewUserDetail}
|
||||
showBanUserDialog={props.showBanUserDialog}
|
||||
showSuspendUserDialog={props.showSuspendUserDialog}
|
||||
@@ -193,8 +191,6 @@ class ModerationQueue extends React.Component {
|
||||
key={comment.id}
|
||||
comment={comment}
|
||||
selected={comment.id === selectedCommentId}
|
||||
suspectWords={props.suspectWords}
|
||||
bannedWords={props.bannedWords}
|
||||
viewUserDetail={viewUserDetail}
|
||||
showBanUserDialog={props.showBanUserDialog}
|
||||
showSuspendUserDialog={props.showSuspendUserDialog}
|
||||
@@ -218,8 +214,6 @@ class ModerationQueue extends React.Component {
|
||||
|
||||
ModerationQueue.propTypes = {
|
||||
viewUserDetail: PropTypes.func.isRequired,
|
||||
bannedWords: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||
suspectWords: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||
currentAsset: PropTypes.object,
|
||||
showBanUserDialog: PropTypes.func.isRequired,
|
||||
showSuspendUserDialog: PropTypes.func.isRequired,
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
@custom-media --tablet (max-width: 1180px);
|
||||
|
||||
.viewOptions {
|
||||
display: inline-block;
|
||||
width: 220px;
|
||||
position: absolute;
|
||||
padding: 0;
|
||||
overflow: visible;
|
||||
height: 144px;
|
||||
min-height: auto;
|
||||
z-index: 10;
|
||||
|
||||
@media (--tablet) {
|
||||
width: 650px;
|
||||
margin: 0 auto;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
margin-top: 10px;
|
||||
height: 60px;
|
||||
}
|
||||
}
|
||||
|
||||
.headline {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
border-bottom: 1px solid rgba(0,0,0,.1);
|
||||
padding: 0 20px;
|
||||
|
||||
@media (--tablet) {
|
||||
flex: 1;
|
||||
display: inline-block;
|
||||
border-bottom: none;
|
||||
line-height: 60px;
|
||||
}
|
||||
}
|
||||
|
||||
.viewOptionsContent {
|
||||
padding: 10px 20px;
|
||||
|
||||
@media (--tablet) {
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
|
||||
.viewOptionsList {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
color: rgba(0,0,0,.54);
|
||||
}
|
||||
|
||||
.viewOptionsItem {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.selectField {
|
||||
position: relative;
|
||||
width: 140px;
|
||||
height: 36px;
|
||||
top: 5px;
|
||||
margin-right: 10px;
|
||||
background: #7B7B7B;
|
||||
color: white;
|
||||
padding: 6px 15px;
|
||||
box-sizing: border-box;
|
||||
border-radius: 2px;
|
||||
box-shadow: 0 2px 2px 0 rgba(0,0,0,.14), 0 3px 1px -2px rgba(0,0,0,.2), 0 1px 5px 0 rgba(0,0,0,.12);
|
||||
|
||||
@media (--tablet) {
|
||||
display: inline-block;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
> div {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
i {
|
||||
position: absolute;
|
||||
top: 7px;
|
||||
right: 7px;
|
||||
}
|
||||
|
||||
input {
|
||||
font-size: 1rem;
|
||||
border-bottom: 0px;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
label {
|
||||
top: -4px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import styles from './ViewOptions.css';
|
||||
import {Card} from 'coral-ui';
|
||||
import cn from 'classnames';
|
||||
import {SelectField, Option} from 'react-mdl-selectfield';
|
||||
import t from 'coral-framework/services/i18n';
|
||||
|
||||
class ViewOptions extends React.Component {
|
||||
render() {
|
||||
|
||||
const {
|
||||
selectSort,
|
||||
sort
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<Card className={cn(styles.viewOptions, 'talk-admin-moderation-view-options')}>
|
||||
<h2 className={cn(styles.headline, 'talk-admin-moderation-view-options-headline')}>
|
||||
View Options
|
||||
</h2>
|
||||
<div className={styles.viewOptionsContent}>
|
||||
<ul className={styles.viewOptionsList}>
|
||||
<li className={styles.viewOptionsItem}>
|
||||
Sort Comments
|
||||
<SelectField
|
||||
className={styles.selectField}
|
||||
label="Sort"
|
||||
value={sort}
|
||||
onChange={(sort) => selectSort(sort)}>
|
||||
<Option value={'DESC'}>{t('modqueue.newest_first')}</Option>
|
||||
<Option value={'ASC'}>{t('modqueue.oldest_first')}</Option>
|
||||
</SelectField>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ViewOptions.propTypes = {
|
||||
selectSort: PropTypes.func.isRequired,
|
||||
sort: PropTypes.string.isRequired
|
||||
};
|
||||
|
||||
export default ViewOptions;
|
||||
@@ -15,7 +15,12 @@ const slots = [
|
||||
export default withFragments({
|
||||
root: gql`
|
||||
fragment CoralAdmin_ModerationComment_root on RootQuery {
|
||||
__typename
|
||||
settings {
|
||||
wordlist {
|
||||
banned
|
||||
suspect
|
||||
}
|
||||
}
|
||||
${getSlotFragmentSpreads(slots, 'root')}
|
||||
...${getDefinitionName(CommentLabels.fragments.root)}
|
||||
...${getDefinitionName(CommentDetails.fragments.root)}
|
||||
|
||||
@@ -13,7 +13,6 @@ import {isPremod, getModPath} from '../../../utils';
|
||||
import {withSetCommentStatus} from 'coral-framework/graphql/mutations';
|
||||
import {handleCommentChange} from '../graphql';
|
||||
|
||||
import {fetchSettings} from 'actions/settings';
|
||||
import {showBanUserDialog} from 'actions/banUserDialog';
|
||||
import {showSuspendUserDialog} from 'actions/suspendUserDialog';
|
||||
import {viewUserDetail} from '../../../actions/userDetail';
|
||||
@@ -146,7 +145,6 @@ class ModerationContainer extends Component {
|
||||
|
||||
componentWillMount() {
|
||||
this.props.clearState();
|
||||
this.props.fetchSettings();
|
||||
this.subscribeToUpdates();
|
||||
}
|
||||
|
||||
@@ -384,7 +382,6 @@ const withModQueueQuery = withQuery(({queueConfig}) => gql`
|
||||
|
||||
const mapStateToProps = (state) => ({
|
||||
moderation: state.moderation,
|
||||
settings: state.settings,
|
||||
auth: state.auth,
|
||||
});
|
||||
|
||||
@@ -392,7 +389,6 @@ const mapDispatchToProps = (dispatch) => ({
|
||||
...bindActionCreators({
|
||||
toggleModal,
|
||||
singleView,
|
||||
fetchSettings,
|
||||
showBanUserDialog,
|
||||
hideShortcutsNote,
|
||||
toggleStorySearch,
|
||||
|
||||
@@ -76,6 +76,7 @@
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.editCommentForm .buttonContainerLeft .editWindowRemaining {
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
.card {
|
||||
margin-bottom: 20px;
|
||||
align-items: flex-start;
|
||||
min-height: 100px;
|
||||
max-width: 600px;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.header {
|
||||
margin-top: 3px;
|
||||
margin-bottom: 7px;
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
width: 100%;
|
||||
font-size: 14px;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.action {
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: inline-block;
|
||||
padding: 0px 30px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.enabledSetting {
|
||||
border-left-color: #00796b;
|
||||
border-left-style: solid;
|
||||
border-left-width: 7px;
|
||||
}
|
||||
|
||||
.disabledSetting {
|
||||
padding-left: 22px;
|
||||
}
|
||||
|
||||
.disabledSettingText {
|
||||
color: #ccc;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import styles from './ConfigureCard.css';
|
||||
import {Card} from 'coral-ui';
|
||||
import {Checkbox} from 'react-mdl';
|
||||
import cn from 'classnames';
|
||||
|
||||
const ConfigureCard = ({title, children, className, onCheckbox, checked, ...rest}) => (
|
||||
<Card {...rest} className={cn(
|
||||
styles.card,
|
||||
className,
|
||||
{
|
||||
[styles.enabledSetting]: checked === true,
|
||||
[styles.disabledSetting]: checked === false,
|
||||
},
|
||||
)}>
|
||||
{checked !== undefined &&
|
||||
<div className={styles.action}>
|
||||
<Checkbox
|
||||
onChange={onCheckbox}
|
||||
checked={checked} />
|
||||
</div>
|
||||
}
|
||||
<div className={cn(styles.wrapper, {
|
||||
[styles.content]: checked !== undefined,
|
||||
})}>
|
||||
<div className={styles.header}>{title}</div>
|
||||
<div className={cn({
|
||||
[styles.disabledSettingText]: checked === false,
|
||||
})}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
|
||||
ConfigureCard.propTypes = {
|
||||
title: PropTypes.string.isRequired,
|
||||
className: PropTypes.string,
|
||||
onCheckbox: PropTypes.func,
|
||||
checked: PropTypes.bool,
|
||||
children: PropTypes.node,
|
||||
};
|
||||
|
||||
export default ConfigureCard;
|
||||
@@ -16,6 +16,7 @@ export default {
|
||||
'ModifyTagResponse',
|
||||
'IgnoreUserResponse',
|
||||
'StopIgnoringUserResponse',
|
||||
'UpdateSettingsResponse',
|
||||
)
|
||||
};
|
||||
|
||||
|
||||
@@ -340,3 +340,21 @@ export const withStopIgnoringUser = withMutation(
|
||||
});
|
||||
}}),
|
||||
});
|
||||
|
||||
export const withUpdateSettings = withMutation(
|
||||
gql`
|
||||
mutation UpdateSettings($input: UpdateSettingsInput!) {
|
||||
updateSettings(input: $input) {
|
||||
...UpdateSettingsResponse
|
||||
}
|
||||
}
|
||||
`, {
|
||||
props: ({mutate}) => ({
|
||||
updateSettings: (input) => {
|
||||
return mutate({
|
||||
variables: {
|
||||
input,
|
||||
},
|
||||
});
|
||||
}}),
|
||||
});
|
||||
|
||||
@@ -3,10 +3,13 @@ import has from 'lodash/has';
|
||||
import get from 'lodash/get';
|
||||
import merge from 'lodash/merge';
|
||||
|
||||
import esTA from '../../../node_modules/timeago.js/locales/es';
|
||||
import frTA from '../../../node_modules/timeago.js/locales/fr';
|
||||
import pt_BRTA from '../../../node_modules/timeago.js/locales/pt_BR';
|
||||
import daTA from 'timeago.js/locales/da';
|
||||
import esTA from 'timeago.js/locales/es';
|
||||
import frTA from 'timeago.js/locales/fr';
|
||||
import pt_BRTA from 'timeago.js/locales/pt_BR';
|
||||
|
||||
import en from '../../../locales/en.yml';
|
||||
import da from '../../../locales/da.yml';
|
||||
import es from '../../../locales/es.yml';
|
||||
import fr from '../../../locales/fr.yml';
|
||||
import pt_BR from '../../../locales/pt_BR.yml';
|
||||
@@ -14,7 +17,7 @@ import pt_BR from '../../../locales/pt_BR.yml';
|
||||
// Translations are happening at https://translate.lingohub.com/the-coral-project/dashboard
|
||||
|
||||
const defaultLanguage = process.env.TALK_DEFAULT_LANG;
|
||||
const translations = {...en, ...es, ...fr, ...pt_BR};
|
||||
const translations = {...en, ...da, ...es, ...fr, ...pt_BR};
|
||||
|
||||
let lang;
|
||||
let timeagoInstance;
|
||||
@@ -44,6 +47,7 @@ function init() {
|
||||
}
|
||||
|
||||
ta.register('es', esTA);
|
||||
ta.register('da', daTA);
|
||||
ta.register('fr', frTA);
|
||||
ta.register('pt_BR', pt_BRTA);
|
||||
timeagoInstance = ta();
|
||||
|
||||
@@ -30,5 +30,3 @@
|
||||
.shadow--3 {
|
||||
box-shadow: 0 3px 4px 0 rgba(0,0,0,.14), 0 3px 3px -2px rgba(0,0,0,.2), 0 1px 8px 0 rgba(0,0,0,.12);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import styles from './Card.css';
|
||||
|
||||
export default ({children, className, shadow = 2, ...props}) => (
|
||||
const Card = ({children, className, shadow = 2, ...props}) => (
|
||||
<div className={`${styles.base} ${className} ${styles[`shadow--${shadow}`]}`} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
Card.propTypes = {
|
||||
children: PropTypes.node,
|
||||
className: PropTypes.string,
|
||||
shadow: PropTypes.number,
|
||||
};
|
||||
|
||||
export default Card;
|
||||
|
||||
@@ -85,9 +85,14 @@ export class CommentForm extends React.Component {
|
||||
render() {
|
||||
const {maxCharCount, submitEnabled, cancelButtonClassName, submitButtonClassName, charCountEnable, body, loadingState} = this.props;
|
||||
|
||||
const length = body.trim().length;
|
||||
const isRespectingMaxCount = (length) => charCountEnable && maxCharCount && length > maxCharCount;
|
||||
const disableSubmitButton = !length || isRespectingMaxCount(length) || !submitEnabled({body}) || loadingState === 'loading';
|
||||
const length = body.length;
|
||||
const isRespectingMaxCount = (length) => charCountEnable && maxCharCount && length > maxCharCount;
|
||||
const disableSubmitButton =
|
||||
!length ||
|
||||
body.trim().length === 0 ||
|
||||
isRespectingMaxCount(length) ||
|
||||
!submitEnabled({body}) ||
|
||||
loadingState === 'loading';
|
||||
const disableCancelButton = loadingState === 'loading';
|
||||
const disableTextArea = loadingState === 'loading';
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ import ClickOutside from 'coral-framework/components/ClickOutside';
|
||||
import cn from 'classnames';
|
||||
import styles from './styles.css';
|
||||
|
||||
import {getErrorMessages} from 'coral-framework/utils';
|
||||
|
||||
const name = 'talk-plugin-flags';
|
||||
|
||||
export default class FlagButton extends Component {
|
||||
@@ -99,6 +101,7 @@ export default class FlagButton extends Component {
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
this.props.notify('error', getErrorMessages(err));
|
||||
console.error(err);
|
||||
});
|
||||
} else {
|
||||
@@ -109,6 +112,7 @@ export default class FlagButton extends Component {
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
this.props.notify('error', getErrorMessages(err));
|
||||
console.error(err);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4,11 +4,8 @@ GEM
|
||||
addressable (2.5.2)
|
||||
public_suffix (>= 2.0.2, < 4.0)
|
||||
colorator (1.1.0)
|
||||
cssminify2 (2.0.1)
|
||||
execjs (2.7.0)
|
||||
ffi (1.9.18)
|
||||
forwardable-extended (2.6.0)
|
||||
htmlcompressor (0.3.1)
|
||||
jekyll (3.5.2)
|
||||
addressable (~> 2.4)
|
||||
colorator (~> 1.0)
|
||||
@@ -20,11 +17,6 @@ GEM
|
||||
pathutil (~> 0.9)
|
||||
rouge (~> 1.7)
|
||||
safe_yaml (~> 1.0)
|
||||
jekyll-minifier (0.1.4)
|
||||
cssminify2 (~> 2.0)
|
||||
htmlcompressor (~> 0.3)
|
||||
jekyll (~> 3.5)
|
||||
uglifier (~> 3.2)
|
||||
jekyll-sass-converter (1.5.0)
|
||||
sass (~> 3.4)
|
||||
jekyll-seo-tag (2.3.0)
|
||||
@@ -50,15 +42,12 @@ GEM
|
||||
sass-listen (4.0.0)
|
||||
rb-fsevent (~> 0.9, >= 0.9.4)
|
||||
rb-inotify (~> 0.9, >= 0.9.7)
|
||||
uglifier (3.2.0)
|
||||
execjs (>= 0.3.0, < 3)
|
||||
|
||||
PLATFORMS
|
||||
ruby
|
||||
|
||||
DEPENDENCIES
|
||||
jekyll (= 3.5.2)
|
||||
jekyll-minifier (~> 0.1.4)
|
||||
jekyll-seo-tag (~> 2.1)
|
||||
tzinfo-data
|
||||
|
||||
|
||||
+15
-1
@@ -13,7 +13,21 @@ items:
|
||||
url: /configuration/
|
||||
- title: Advanced Configuration
|
||||
url: /advanced-configuration/
|
||||
- title: Product Guide
|
||||
children:
|
||||
- title: How Talk Works
|
||||
url: /how-talk-works/
|
||||
- title: Trust
|
||||
url: /trust/
|
||||
- title: Toxic Comments
|
||||
url: /toxic-comments/
|
||||
- title: Plugins
|
||||
children:
|
||||
- title: Plugins Overview
|
||||
url: /plugins/
|
||||
url: /plugins/
|
||||
- title: Default Plugins
|
||||
url: /default-plugins/
|
||||
- title: Additional Plugins
|
||||
url: /additional-plugins/
|
||||
- title: Plugin Recipes
|
||||
url: /plugin-recipes/
|
||||
|
||||
+7
-7
@@ -19,7 +19,7 @@ If you've already configured your application with the required configuration,
|
||||
you can further customize it's behavior by applying
|
||||
[Advanced Configuration]({{ "/advanced-configuration/" | relative_url }}).
|
||||
|
||||
## TALK_MONGO_URL
|
||||
### TALK_MONGO_URL
|
||||
|
||||
The database connection string for the MongoDB database. This usually takes the
|
||||
form of:
|
||||
@@ -31,7 +31,7 @@ TALK_MONGO_URL=mongodb://<DATABASE USER>:<DATABASE PASSWORD>@<DATABASE HOST>:<DA
|
||||
Refer to [connection string uri format](https://docs.mongodb.com/manual/reference/connection-string/){:target="_blank"}
|
||||
for the detailed url scheme of the MongoDB url.
|
||||
|
||||
## TALK_REDIS_URL
|
||||
### TALK_REDIS_URL
|
||||
|
||||
The database connection string for the Redis database. This usually takes the
|
||||
form of:
|
||||
@@ -50,7 +50,7 @@ TALK_REDIS_URL=redis://127.0.0.1:6379/2
|
||||
Refer to [uri scheme](http://www.iana.org/assignments/uri-schemes/prov/redis){:target="_blank"}
|
||||
for the detailed url scheme of the Redis url.
|
||||
|
||||
## TALK_ROOT_URL
|
||||
### TALK_ROOT_URL
|
||||
|
||||
The root url of the installed application externally available in the format:
|
||||
|
||||
@@ -70,7 +70,7 @@ TALK_ROOT_URL=https://talk.coralproject.net/
|
||||
_Note that we omitted the `PORT`, as it was implied by setting the `SCHEME` to
|
||||
`https`._
|
||||
|
||||
## TALK_JWT_SECRET
|
||||
### TALK_JWT_SECRET
|
||||
|
||||
Used to specify the application signing secret. You can specify this using a
|
||||
simple string, we recommend using a password generator and pasting it's output.
|
||||
@@ -85,7 +85,7 @@ Be default, we sign our tokens with HMAC using a SHA-256 hash algorithm. If you
|
||||
want to change the signing algorithm, or use multiple signing/verifying keys,
|
||||
refer to our [Advanced Configuration]({{ "/advanced-configuration/" | relative_url }}) documentation.
|
||||
|
||||
## TALK_FACEBOOK_APP_ID
|
||||
### TALK_FACEBOOK_APP_ID
|
||||
|
||||
The Facebook App ID for your Facebook Login enabled app. You can learn more
|
||||
about getting a Facebook App ID at the
|
||||
@@ -95,7 +95,7 @@ or by visiting the
|
||||
guide. This is only required while the `talk-plugin-facebook-auth` plugin is
|
||||
enabled.
|
||||
|
||||
## TALK_FACEBOOK_APP_SECRET
|
||||
### TALK_FACEBOOK_APP_SECRET
|
||||
|
||||
The Facebook App Secret for your Facebook Login enabled app. You can learn more
|
||||
about getting a Facebook App Secret at the
|
||||
@@ -103,4 +103,4 @@ about getting a Facebook App Secret at the
|
||||
or by visiting the
|
||||
[Creating an App ID](https://developers.facebook.com/docs/apps/register){:target="_blank"}
|
||||
guide. This is only required while the `talk-plugin-facebook-auth` plugin is
|
||||
enabled.
|
||||
enabled.
|
||||
+41
-41
@@ -19,37 +19,37 @@ If this is your first time configuring Talk, ensure you've also added the
|
||||
[Required Configuration]({{ "/configuration/" | relative_url }}) as well,
|
||||
otherwise the application will fail to start.
|
||||
|
||||
## TALK_CACHE_EXPIRY_COMMENT_COUNT
|
||||
### TALK_CACHE_EXPIRY_COMMENT_COUNT
|
||||
|
||||
Configure the duration for which comment counts are cached for, parsed by
|
||||
[ms](https://www.npmjs.com/package/ms){:target="_blank"}. (Default `1hr`)
|
||||
|
||||
## TALK_DEFAULT_LANG
|
||||
### TALK_DEFAULT_LANG
|
||||
|
||||
Specify the default translation language. (Default `en`)
|
||||
|
||||
## TALK_DEFAULT_STREAM_TAB
|
||||
### TALK_DEFAULT_STREAM_TAB
|
||||
|
||||
Specify the default stream tab in the admin. (Default `all`)
|
||||
|
||||
## TALK_DISABLE_AUTOFLAG_SUSPECT_WORDS
|
||||
### TALK_DISABLE_AUTOFLAG_SUSPECT_WORDS
|
||||
|
||||
When `TRUE`, disables flagging of comments that match the suspect word filter. (Default `FALSE`)
|
||||
|
||||
## TALK_DISABLE_EMBED_POLYFILL
|
||||
### TALK_DISABLE_EMBED_POLYFILL
|
||||
|
||||
When set to `TRUE`, the build process will not include the
|
||||
[babel-polyfill](https://babeljs.io/docs/usage/polyfill/){:target="_blank"}
|
||||
in the embed.js target that is loaded on the page that loads the embed. (Default
|
||||
`FALSE`)
|
||||
|
||||
## TALK_DISABLE_STATIC_SERVER
|
||||
### TALK_DISABLE_STATIC_SERVER
|
||||
|
||||
When `TRUE`, it will not mount the static asset serving routes on the router.
|
||||
This is used primarily in conjunction with [TALK_STATIC_URI](#talk_static_uri){: .param}
|
||||
when the static assets are being hosted on an external domain. (Default `FALSE`)
|
||||
|
||||
## TALK_HELMET_CONFIGURATION
|
||||
### TALK_HELMET_CONFIGURATION
|
||||
|
||||
A JSON string representing the configuration passed to the
|
||||
[helmet](https://github.com/helmetjs/helmet){:target="_blank"} middleware. It
|
||||
@@ -68,20 +68,20 @@ TALK_HELMET_CONFIGURATION={"hsts": false}
|
||||
|
||||
To disable these headers from being sent.
|
||||
|
||||
## TALK_INSTALL_LOCK
|
||||
### TALK_INSTALL_LOCK
|
||||
|
||||
When `TRUE`, disables the dynamic setup endpoint `/admin/install` from even
|
||||
loading. This prevents hits to the database with enabled. This should always be
|
||||
set to `TRUE` after you've deployed Talk. (Default `FALSE`)
|
||||
|
||||
## TALK_JWT_ALG
|
||||
### TALK_JWT_ALG
|
||||
|
||||
The algorithm used to sign/verify JWT’s used for session management. Read up
|
||||
about alternative algorithms on the
|
||||
[jsonwebtoken](https://www.npmjs.com/package/jsonwebtoken#algorithms-supported){:target="_blank"}
|
||||
package. (Default `HS256`)
|
||||
|
||||
### Shared Secret
|
||||
#### Shared Secret
|
||||
{:.no_toc}
|
||||
|
||||
You would use a shared secret when you have no need to share the tokens with
|
||||
@@ -102,7 +102,7 @@ These must be provided in the form:
|
||||
```
|
||||
{: .no-copy}
|
||||
|
||||
### Asymmetric Secret
|
||||
#### Asymmetric Secret
|
||||
{:.no_toc}
|
||||
|
||||
You would use a asymmetric secret when you want to share the token in your
|
||||
@@ -138,23 +138,23 @@ certificates that match our required format: [coralcert](https://github.com/cora
|
||||
This tool can generate RSA and ECDSA certificates, check it's [README](https://github.com/coralproject/coralcert){:target="_blank"}
|
||||
for more details.
|
||||
|
||||
## TALK_JWT_AUDIENCE
|
||||
### TALK_JWT_AUDIENCE
|
||||
|
||||
The audience [aud](https://tools.ietf.org/html/rfc7519#section-4.1.3){:target="_blank"}
|
||||
claim for login JWT tokens. (Default `talk`)
|
||||
|
||||
## TALK_JWT_CLEAR_COOKIE_LOGOUT
|
||||
### TALK_JWT_CLEAR_COOKIE_LOGOUT
|
||||
|
||||
When `FALSE`, Talk will not clear the cookie with name
|
||||
[TALK_JWT_SIGNING_COOKIE_NAME](#talk_jwt_signing_cookie_name){: .param} when logging out
|
||||
but will still blacklist the token. (Default `TRUE`)
|
||||
|
||||
## TALK_JWT_COOKIE_NAME
|
||||
### TALK_JWT_COOKIE_NAME
|
||||
|
||||
The default cookie name to check for a valid JWT token to use for verifying a
|
||||
user. (Default `authorization`)
|
||||
|
||||
## TALK_JWT_COOKIE_NAMES
|
||||
### TALK_JWT_COOKIE_NAMES
|
||||
|
||||
The different cookie names to check for a JWT token in, separated by a `,`. By
|
||||
default, we always use the value of [TALK_JWT_COOKIE_NAME](#talk_jwt_cookie_name){: .param}
|
||||
@@ -176,19 +176,19 @@ Would mean we would check the following cookies (in order) for a valid token:
|
||||
2. `coralproject.talk`
|
||||
3. `coralproject.auth`
|
||||
|
||||
## TALK_JWT_DISABLE_AUDIENCE
|
||||
### TALK_JWT_DISABLE_AUDIENCE
|
||||
|
||||
When `TRUE`, Talk will not verify or sign JWT’s with an audience
|
||||
[aud](https://tools.ietf.org/html/rfc7519#section-4.1.3){:target="_blank"}
|
||||
claim, even if [TALK_JWT_AUDIENCE](#talk_jwt_audience){: .param} is set. (Default `FALSE`)
|
||||
|
||||
## TALK_JWT_DISABLE_ISSUER
|
||||
### TALK_JWT_DISABLE_ISSUER
|
||||
|
||||
When `TRUE`, Talk will not verify or sign JWT’s with an issuer
|
||||
[iss](https://tools.ietf.org/html/rfc7519#section-4.1.1){:target="_blank"}
|
||||
claim, even if [TALK_JWT_ISSUER](#talk_jwt_issuer){: .param} is set. (Default `FALSE`)
|
||||
|
||||
## TALK_JWT_EXPIRY
|
||||
### TALK_JWT_EXPIRY
|
||||
|
||||
The expiry duration [exp](https://tools.ietf.org/html/rfc7519#section-4.1.4){:target="_blank"}
|
||||
for the tokens issued for logged in sessions, parsed by
|
||||
@@ -199,12 +199,12 @@ If the user logs out, then an entry is created in the token blacklist of it's
|
||||
set to be automatically removed at it's expiry time. It is important for this
|
||||
reason to create reasonable expiry lengths as to minimize the storage overhead.
|
||||
|
||||
## TALK_JWT_ISSUER
|
||||
### TALK_JWT_ISSUER
|
||||
|
||||
The issuer [iss](https://tools.ietf.org/html/rfc7519#section-4.1.1){:target="_blank"}
|
||||
claim for login JWT tokens. (Defaults to value of [TALK_ROOT_URL]({{ "/configuration/#talk_root_url" | relative_url }}){: .param})
|
||||
|
||||
## TALK_JWT_SECRET
|
||||
### TALK_JWT_SECRET
|
||||
|
||||
Used to specify the application signing secret. You can specify this using a
|
||||
simple string, we recommend using a password generator and pasting it's output.
|
||||
@@ -225,7 +225,7 @@ Refer to the documentation for [TALK_JWT_ALG](#talk_jwt_alg){: .param} for other
|
||||
methods and other forms of the `TALK_JWT_SECRET`. If you are interested in using
|
||||
multiple keys, then refer to [TALK_JWT_SECRETS](#talk_jwt_secrets){: .param}.
|
||||
|
||||
## TALK_JWT_SECRETS
|
||||
### TALK_JWT_SECRETS
|
||||
|
||||
Used when specifying multiple secrets used for key rotations. This is a JSON
|
||||
encoded array, where each element matches the JWT Secret pattern. When this is
|
||||
@@ -261,12 +261,12 @@ Refer to the documentation on the [TALK_JWT_ALG](#talk_jwt_alg){: .param} for mo
|
||||
information on what to store in these parameters.
|
||||
{: .code-aside}
|
||||
|
||||
## TALK_JWT_SIGNING_COOKIE_NAME
|
||||
### TALK_JWT_SIGNING_COOKIE_NAME
|
||||
|
||||
The default cookie name that is use to set a cookie containing a JWT that was
|
||||
issued by Talk. (Defaults to value of [TALK_JWT_COOKIE_NAME](#talk_jwt_cookie_name){: .param})
|
||||
|
||||
## TALK_JWT_USER_ID_CLAIM
|
||||
### TALK_JWT_USER_ID_CLAIM
|
||||
|
||||
Specify the claim using dot notation for where the user id should be stored/read
|
||||
to/from. (Default `sub`)
|
||||
@@ -287,13 +287,13 @@ Then we would set `TALK_JWT_USER_ID_CLAIM` to:
|
||||
TALK_JWT_USER_ID_CLAIM=user.id
|
||||
```
|
||||
|
||||
## TALK_KEEP_ALIVE
|
||||
### TALK_KEEP_ALIVE
|
||||
|
||||
The keepalive timeout that should be used to send keep alive messages through
|
||||
the websocket to keep the socket alive, parsed by
|
||||
[ms](https://www.npmjs.com/package/ms){:target="_blank"}. (Default `30s`)
|
||||
|
||||
## TALK_RECAPTCHA_PUBLIC
|
||||
### TALK_RECAPTCHA_PUBLIC
|
||||
|
||||
Client secret used for enabling reCAPTCHA powered logins. If
|
||||
[TALK_RECAPTCHA_SECRET](#talk_recaptcha_secret){: .param} and
|
||||
@@ -302,7 +302,7 @@ default to providing only a time based lockout. Refer to
|
||||
[reCAPTCHA](https://www.google.com/recaptcha/intro/index.html) for information
|
||||
on getting an account setup.
|
||||
|
||||
## TALK_RECAPTCHA_SECRET
|
||||
### TALK_RECAPTCHA_SECRET
|
||||
|
||||
Server secret used for enabling reCAPTCHA powered logins. If
|
||||
[TALK_RECAPTCHA_SECRET](#talk_recaptcha_secret){: .param} and
|
||||
@@ -311,7 +311,7 @@ default to providing only a time based lockout. Refer to
|
||||
[reCAPTCHA](https://www.google.com/recaptcha/intro/index.html) for information
|
||||
on getting an account setup.
|
||||
|
||||
## TALK_REDIS_CLIENT_CONFIG
|
||||
### TALK_REDIS_CLIENT_CONFIG
|
||||
|
||||
Configuration overrides for the redis client configuration in a JSON encoded
|
||||
string. Configuration is overridden as the second parameter to the redis client
|
||||
@@ -319,30 +319,30 @@ constructor, and is merged with default configuration. Refer to the
|
||||
[ioredis](https://github.com/luin/ioredis){:target="_blank"} docs on the
|
||||
available options. (Default `{}`)
|
||||
|
||||
## TALK_REDIS_CLUSTER_CONFIGURATION
|
||||
### TALK_REDIS_CLUSTER_CONFIGURATION
|
||||
|
||||
The JSON encoded form of the cluster nodes. Only required when
|
||||
[TALK_REDIS_CLUSTER_MODE](#talk_redis_cluster_mode){: .param} is `CLUSTER`. See
|
||||
[https://github.com/luin/ioredis#cluster](https://github.com/luin/ioredis#cluster){:target="_blank"}
|
||||
for configuration details. (Default `[]`)
|
||||
|
||||
## TALK_REDIS_CLUSTER_MODE
|
||||
### TALK_REDIS_CLUSTER_MODE
|
||||
|
||||
The cluster mode of the redis client. Can be either `NONE` or `CLUSTER`.
|
||||
(Default `NONE`)
|
||||
|
||||
## TALK_REDIS_RECONNECTION_BACKOFF_FACTOR
|
||||
### TALK_REDIS_RECONNECTION_BACKOFF_FACTOR
|
||||
|
||||
The time factor that will be multiplied against the current attempt count
|
||||
between attempts to connect to redis, parsed by
|
||||
[ms](https://www.npmjs.com/package/ms){:target="_blank"}. (Default `500 ms`)
|
||||
|
||||
## TALK_REDIS_RECONNECTION_BACKOFF_MINIMUM_TIME
|
||||
### TALK_REDIS_RECONNECTION_BACKOFF_MINIMUM_TIME
|
||||
|
||||
The minimum time used to delay before attempting to reconnect to redis, parsed
|
||||
by [ms](https://www.npmjs.com/package/ms){:target="_blank"}. (Default `1 sec`)
|
||||
|
||||
## TALK_ROOT_URL_MOUNT_PATH
|
||||
### TALK_ROOT_URL_MOUNT_PATH
|
||||
|
||||
When set to `TRUE`, the routes will be mounted onto the `<PATHNAME>` component
|
||||
of the [TALK_ROOT_URL]({{ "/configuration/#talk_root_url" | relative_url }}){: .param}.
|
||||
@@ -361,7 +361,7 @@ Then all the routes for the API will be expecting to be hit on `/talk/`, such as
|
||||
can perform the path stripping when serving an upstream proxy, but some CDN's
|
||||
cannot. You would use this option in the latter situation.
|
||||
|
||||
## TALK_SMTP_EMAIL
|
||||
### TALK_SMTP_EMAIL
|
||||
|
||||
The email address to send emails from using the SMTP provider in the format:
|
||||
|
||||
@@ -371,23 +371,23 @@ TALK_SMTP_EMAIL="The Coral Project" <support@coralproject.net>
|
||||
|
||||
Including the name and email address.
|
||||
|
||||
## TALK_SMTP_HOST
|
||||
### TALK_SMTP_HOST
|
||||
|
||||
The domain for the SMTP provider that you are using.
|
||||
|
||||
## TALK_SMTP_PASSWORD
|
||||
### TALK_SMTP_PASSWORD
|
||||
|
||||
The password for the SMTP provider you are using.
|
||||
|
||||
## TALK_SMTP_PORT
|
||||
### TALK_SMTP_PORT
|
||||
|
||||
The port for the SMTP provider that you are using.
|
||||
|
||||
## TALK_SMTP_USERNAME
|
||||
### TALK_SMTP_USERNAME
|
||||
|
||||
The username of the SMTP provider you are using.
|
||||
|
||||
## TALK_STATIC_URI
|
||||
### TALK_STATIC_URI
|
||||
|
||||
Used to set the uri where the static assets should be served from. This is used
|
||||
when you want to upload the static assets through your build process to a
|
||||
@@ -395,14 +395,14 @@ service like Google Cloud Storage or Amazon S3 and you would then specify the
|
||||
CDN/Storage url. (Defaults to value of
|
||||
[TALK_ROOT_URL]({{ "/configuration/#talk_root_url" | relative_url }}){: .param})
|
||||
|
||||
## TALK_THREADING_LEVEL
|
||||
### TALK_THREADING_LEVEL
|
||||
|
||||
Specify the maximum depth of the comment thread. (Default `3`)
|
||||
|
||||
**Note that a high value for `TALK_THREADING_LEVEL` will result in large
|
||||
performance impacts.**
|
||||
|
||||
## TALK_WEBSOCKET_LIVE_URI
|
||||
### TALK_WEBSOCKET_LIVE_URI
|
||||
|
||||
Used to override the location to connect to the websocket endpoint to
|
||||
potentially another host. This should be used when you need to route websocket
|
||||
@@ -419,7 +419,7 @@ is `FALSE`, or the path component of
|
||||
**Warning: if used without managing the auth state manually, auth
|
||||
cannot be persisted due to browser restrictions.**
|
||||
|
||||
## TRUST_THRESHOLDS
|
||||
### TRUST_THRESHOLDS
|
||||
|
||||
Configure the reliability thresholds for flagging and commenting. (Default
|
||||
`comment:2,-1;flag:2,-1`)
|
||||
@@ -116,4 +116,4 @@ assets inside the image as well.
|
||||
|
||||
For more information on the onbuild image, refer to the
|
||||
[Installation from Docker]({{ "/installation-from-docker/" | relative_url }})
|
||||
documentation.
|
||||
documentation.
|
||||
@@ -0,0 +1,152 @@
|
||||
---
|
||||
title: Default Plugins
|
||||
permalink: /default-plugins/
|
||||
class: configuration
|
||||
---
|
||||
|
||||
The default Talk plugins can be found in the `plugins.default.json` file
|
||||
[here](https://github.com/coralproject/talk/blob/master/plugins.default.json).
|
||||
|
||||
Talk ships out of the box with these plugins enabled:
|
||||
|
||||
{% include toc.html %}
|
||||
|
||||
We ship [Additional Plugins]({{ "/additional-plugins/" | relative_url }}) with
|
||||
Talk that are not enabled by default. You can enable these or disable these
|
||||
default plugins by consulting the [Plugins Overview]({{ "/plugins/" | relative_url }})
|
||||
page.
|
||||
|
||||
### talk-plugin-auth
|
||||
|
||||
Source: [plugins/talk-plugin-auth](https://github.com/coralproject/talk/tree/master/plugins/talk-plugin-auth){:target="_blank"}
|
||||
|
||||
Enables generic registration via an email address, a username, a password, and a
|
||||
password confirmation. To sync Talk auth with your own auth systems, you can use
|
||||
this plugin as a template.
|
||||
|
||||
### talk-plugin-facebook-auth
|
||||
|
||||
Source: [plugins/talk-plugin-auth](https://github.com/coralproject/talk/tree/master/plugins/talk-plugin-auth){:target="_blank"}
|
||||
|
||||
Requires: [talk-plugin-facebook-auth](#talk-plugin-facebook-auth){:.param}
|
||||
|
||||
Enables sign-in via Facebook via the server side passport middleware.
|
||||
|
||||
Configuration:
|
||||
|
||||
{:.no_toc}
|
||||
|
||||
- [TALK_FACEBOOK_APP_ID]({{ "/configuration/#talk_facebook_app_id" | relative_url }}){:.param} (**required**) - See the existing documentation for the [TALK_FACEBOOK_APP_ID]({{ "/configuration/#talk_facebook_app_id" | relative_url }}){:.param}.
|
||||
- [TALK_FACEBOOK_APP_SECRET]({{ "/configuration/#talk_facebook_app_secret" | relative_url }}){:.param} (**required**) - See the existing documentation for the [TALK_FACEBOOK_APP_SECRET]({{ "/configuration/#talk_facebook_app_secret" | relative_url }}){:.param}.
|
||||
|
||||
### talk-plugin-featured-comments
|
||||
|
||||
Source: [plugins/talk-plugin-featured-comments](https://github.com/coralproject/talk/tree/master/plugins/talk-plugin-featured-comments){:target="_blank"}
|
||||
|
||||
Enables the ability for Moderators to feature and un-feature comments via the
|
||||
Stream and the Admin. Featured comments show in a first-place tab on the Stream
|
||||
if there are any featured comments on that story.
|
||||
|
||||
### talk-plugin-respect
|
||||
|
||||
Source: [plugins/talk-plugin-respect](https://github.com/coralproject/talk/tree/master/plugins/talk-plugin-respect){:target="_blank"}
|
||||
|
||||
Enables a `respect` reaction button. Why a "respect" button, you ask?
|
||||
[Read more here](https://mediaengagement.org/research/engagement-buttons/).
|
||||
|
||||
### talk-plugin-comment-content
|
||||
|
||||
Source: [plugins/talk-plugin-comment-content](https://github.com/coralproject/talk/tree/master/plugins/talk-plugin-comment-content){:target="_blank"}
|
||||
|
||||
Pluginizes the text of a comment to support custom treatment of this text. This
|
||||
plugin currently parses the given text to see if it contains a link, and makes
|
||||
them clickable.
|
||||
|
||||
### talk-plugin-ignore-user
|
||||
|
||||
Source: [plugins/talk-plugin-ignore-user](https://github.com/coralproject/talk/tree/master/plugins/talk-plugin-ignore-user){:target="_blank"}
|
||||
|
||||
Enables ability for users to ignore (or "mute") other users. If a user is
|
||||
ignored, you will not see any of their comments. You can un-ignore a user via
|
||||
the My Profile tab.
|
||||
|
||||
### talk-plugin-permalink
|
||||
|
||||
Source: [plugins/talk-plugin-permalink](https://github.com/coralproject/talk/tree/master/plugins/talk-plugin-permalink){:target="_blank"}
|
||||
|
||||
Enables a `Link` button that will provide a permalink to the comment that can be
|
||||
shared with others.
|
||||
|
||||
### talk-plugin-viewing-options
|
||||
|
||||
Source: [plugins/talk-plugin-viewing-options](https://github.com/coralproject/talk/tree/master/plugins/talk-plugin-viewing-options){:target="_blank"}
|
||||
|
||||
Pluginizes the sorting/viewing options for a comment stream.
|
||||
|
||||
### talk-plugin-sort-newest
|
||||
|
||||
Source: [plugins/talk-plugin-sort-newest](https://github.com/coralproject/talk/tree/master/plugins/talk-plugin-sort-newest){:target="_blank"}
|
||||
|
||||
Requires: [talk-plugin-viewing-options](#talk-plugin-viewing-options){:.param}
|
||||
|
||||
Provides a sort for the newest comments first. This isn't necessarily required
|
||||
as the default sort without options/plugins is newest first.
|
||||
|
||||
### talk-plugin-sort-oldest
|
||||
|
||||
Source: [plugins/talk-plugin-sort-oldest](https://github.com/coralproject/talk/tree/master/plugins/talk-plugin-sort-oldest){:target="_blank"}
|
||||
|
||||
Requires: [talk-plugin-viewing-options](#talk-plugin-viewing-options){:.param}
|
||||
|
||||
Provides a sort for the newest comments first.
|
||||
|
||||
### talk-plugin-most-respected
|
||||
|
||||
Source: [plugins/talk-plugin-most-respected](https://github.com/coralproject/talk/tree/master/plugins/talk-plugin-most-respected){:target="_blank"}
|
||||
|
||||
Requires: [talk-plugin-viewing-options](#talk-plugin-viewing-options){:.param}, [talk-plugin-respect](#talk-plugin-respect){:.param}
|
||||
|
||||
Provides a sort for the comments with the most `respect` reactions first.
|
||||
|
||||
### talk-plugin-most-replied
|
||||
|
||||
Source: [plugins/talk-plugin-most-replied](https://github.com/coralproject/talk/tree/master/plugins/talk-plugin-most-replied){:target="_blank"}
|
||||
|
||||
Requires: [talk-plugin-viewing-options](#talk-plugin-viewing-options){:.param}
|
||||
|
||||
Provides a sort for the comments with the most replies first.
|
||||
|
||||
### talk-plugin-offtopic
|
||||
|
||||
Source: [plugins/talk-plugin-offtopic](https://github.com/coralproject/talk/tree/master/plugins/talk-plugin-offtopic){:target="_blank"}
|
||||
|
||||
Allows the comment authors to tag their comment as `Off-Topic` which will add a
|
||||
visible badge on the frontend to other users that their comment is off-topic.
|
||||
|
||||
### talk-plugin-author-menu
|
||||
|
||||
Source: [plugins/talk-plugin-author-menu](https://github.com/coralproject/talk/tree/master/plugins/talk-plugin-author-menu){:target="_blank"}
|
||||
|
||||
Pluginizes the author's name on hover.
|
||||
|
||||
### talk-plugin-member-since
|
||||
|
||||
Source: [plugins/talk-plugin-member-since](https://github.com/coralproject/talk/tree/master/plugins/talk-plugin-member-since){:target="_blank"}
|
||||
|
||||
Requires: [talk-plugin-author-menu](#talk-plugin-author-menu){:.param}
|
||||
|
||||
Displays the date that the user was created as a `Member Since ${created_at}`.
|
||||
|
||||
### talk-plugin-moderation-actions
|
||||
|
||||
Source: [plugins/talk-plugin-moderation-actions](https://github.com/coralproject/talk/tree/master/plugins/talk-plugin-moderation-actions){:target="_blank"}
|
||||
|
||||
Enables in-stream moderation so that Moderators can reject, approve comments,
|
||||
as well as ban users, directly from the comment stream. When [talk-plugin-featured-comments](#talk-plugin-featured-comments){:.param} is enabled
|
||||
|
||||
### talk-plugin-flag-details
|
||||
|
||||
Source: [plugins/talk-plugin-flag-details](https://github.com/coralproject/talk/tree/master/plugins/talk-plugin-flag-details){:target="_blank"}
|
||||
|
||||
Pluginizes the Flag Details area of comments in the Moderation Queues to display
|
||||
data. Some basic details are already included on flags by default.
|
||||
@@ -0,0 +1,106 @@
|
||||
---
|
||||
title: Additional Plugins
|
||||
permalink: /additional-plugins/
|
||||
class: configuration
|
||||
---
|
||||
|
||||
Talk ships with several plugins that aren't enabled by default:
|
||||
|
||||
{% include toc.html %}
|
||||
|
||||
These plugins can be enabled by consulting the
|
||||
[Plugins Overview]({{ "/plugins/" | relative_url }}) page.
|
||||
|
||||
### talk-plugin-like
|
||||
|
||||
Source: [plugins/talk-plugin-like](https://github.com/coralproject/talk/tree/master/plugins/talk-plugin-like){:target="_blank"}
|
||||
|
||||
Enables a `like` reaction button.
|
||||
|
||||
### talk-plugin-sort-most-liked
|
||||
|
||||
Source: [plugins/talk-plugin-sort-most-liked](https://github.com/coralproject/talk/tree/master/plugins/talk-plugin-sort-most-liked){:target="_blank"}
|
||||
|
||||
Requires: [talk-plugin-viewing-options]({{ "/default-plugins/#talk-plugin-viewing-options" | relative_url }}){:.param}, [talk-plugin-like](#talk-plugin-like){:.param}
|
||||
|
||||
Provides a sort for the comments with the most `like` reactions first.
|
||||
|
||||
### talk-plugin-love
|
||||
|
||||
Source: [plugins/talk-plugin-love](https://github.com/coralproject/talk/tree/master/plugins/talk-plugin-love){:target="_blank"}
|
||||
|
||||
Enables a `love` reaction button.
|
||||
|
||||
### talk-plugin-sort-most-loved
|
||||
|
||||
Source: [plugins/talk-plugin-sort-most-loved](https://github.com/coralproject/talk/tree/master/plugins/talk-plugin-sort-most-loved){:target="_blank"}
|
||||
|
||||
Requires: [talk-plugin-viewing-options]({{ "/default-plugins/#talk-plugin-viewing-options" | relative_url }}){:.param}, [talk-plugin-love](#talk-plugin-love){:.param}
|
||||
|
||||
Provides a sort for the comments with the most `love` reactions first.
|
||||
|
||||
### talk-plugin-remember-sort
|
||||
|
||||
Source: [plugins/talk-plugin-remember-sort](https://github.com/coralproject/talk/tree/master/plugins/talk-plugin-remember-sort){:target="_blank"}
|
||||
|
||||
Requires: [talk-plugin-viewing-options]({{ "/default-plugins/#talk-plugin-viewing-options" | relative_url }}){:.param}
|
||||
|
||||
Enables saving a user’s last sort selection as they browse other articles.
|
||||
|
||||
### talk-plugin-deep-reply-count
|
||||
|
||||
Source: [plugins/talk-plugin-deep-reply-count](https://github.com/coralproject/talk/tree/master/plugins/talk-plugin-deep-reply-count){:target="_blank"}
|
||||
|
||||
Enables counting of comments to include replies via a new graph edge. Not
|
||||
recommended for large installations as it will unreasonably reduce the query
|
||||
efficiency to compute this number.
|
||||
|
||||
### talk-plugin-slack-notifications
|
||||
|
||||
Source: [plugins/talk-plugin-slack-notifications](https://github.com/coralproject/talk/tree/master/plugins/talk-plugin-slack-notifications){:target="_blank"}
|
||||
|
||||
Enables all new comments that are written to be posted to a Slack channel as
|
||||
well. Configure an
|
||||
[Incoming Webhook](https://api.slack.com/incoming-webhooks){:target="_blank"}
|
||||
app and provide that url in the form of the `SLACK_WEBHOOK_URL`
|
||||
detailed below.
|
||||
|
||||
*Warning: On high volume sites, this means every single comment will flow into
|
||||
Slack, if this isn't what you want, be sure to use the provided plugin as a
|
||||
recipe to further customize the behavior*.
|
||||
|
||||
Configuration:
|
||||
{:.no_toc}
|
||||
|
||||
- `SLACK_WEBHOOK_URL` (**required**) - The webhook url that will be
|
||||
used to post new comments to.
|
||||
|
||||
### talk-plugin-toxic-comments
|
||||
|
||||
Source: [plugins/talk-plugin-toxic-comments](https://github.com/coralproject/talk/tree/master/plugins/talk-plugin-toxic-comments){:target="_blank"}
|
||||
|
||||
Using the [Perspective API](http://perspectiveapi.com/){:target="_blank"}, this
|
||||
plugin will warn users and reject comments that exceed the predefined toxicity
|
||||
threshold. For more information on what Toxic Comments are, check out the
|
||||
[Toxic Comments]({{ "/toxic-comments/" | relative_url }}) documentation.
|
||||
|
||||
Configuration:
|
||||
{:.no_toc}
|
||||
|
||||
- `TALK_PERSPECTIVE_API_KEY` (**required**) - The API Key for Perspective. You
|
||||
can register and get your own key at [http://perspectiveapi.com/](http://perspectiveapi.com/){:target="_blank"}.
|
||||
- `TALK_TOXICITY_THRESHOLD` - If the comments toxicity exceeds this threshold,
|
||||
the comment will be rejected. (Default `0.8`)
|
||||
- `TALK_PERSPECTIVE_API_ENDPOINT` - API Endpoint for hitting the
|
||||
perspective API. (Default `https://commentanalyzer.googleapis.com/v1alpha1`)
|
||||
- `TALK_PERSPECTIVE_TIMEOUT` - The timeout for sending a comment to
|
||||
be processed before it will skip the toxicity analysis, parsed by
|
||||
[ms](https://www.npmjs.com/package/ms){:target="_blank"}. (Default `300ms`)
|
||||
|
||||
### talk-plugin-subscriber
|
||||
|
||||
Source: [plugins/talk-plugin-subscriber](https://github.com/coralproject/talk/tree/master/plugins/talk-plugin-subscriber){:target="_blank"}
|
||||
|
||||
Enables a `Subscriber` badge to be added to comments where the author has the
|
||||
`SUBSCRIBER` tag. This must match with a custom auth integration that adds the
|
||||
tag to the users that are subscribed to the service.
|
||||
@@ -0,0 +1,50 @@
|
||||
---
|
||||
title: Plugin Recipes
|
||||
permalink: /plugin-recipes/
|
||||
class: configuration
|
||||
---
|
||||
|
||||
Plugin Recipes are plugin templates used to help bootstrap the development of a
|
||||
plugin. Recipes are available at the
|
||||
[coralproject/talk-recipes](https://github.com/coralproject/talk-recipes) repo.
|
||||
When first developing a plugin with a recipe, you can simply visit the
|
||||
aforementioned repository to find the desired recipe, and using the file
|
||||
listings on the page, determine which files need to be modified to suit your
|
||||
needs.
|
||||
|
||||
The following are the available recipes for use:
|
||||
|
||||
{% include toc.html %}
|
||||
|
||||
### recipe-avatar
|
||||
|
||||
Source: [talk-recipes/tree/master/plugins/avatar](https://github.com/coralproject/talk-recipes/tree/master/plugins/avatar){:target="_blank"}
|
||||
|
||||
Provides support for avatars hosted via third party, extends the User Model and
|
||||
provides UI on the client-side too.
|
||||
|
||||
|
||||
### recipe-translations
|
||||
|
||||
Source: [talk-recipes/tree/master/plugins/translations](https://github.com/coralproject/talk-recipes/tree/master/plugins/translations){:target="_blank"}
|
||||
|
||||
Provides an example for overriding application text through the translation
|
||||
system.
|
||||
|
||||
|
||||
### recipe-subscriber
|
||||
|
||||
Source: [talk-recipes/tree/master/plugins/subscriber](https://github.com/coralproject/talk-recipes/tree/master/plugins/subscriber){:target="_blank"}
|
||||
|
||||
Provides an example for adding `SUBSCRIBER` badges for users with the
|
||||
`SUBSCRIBER` tag added to their user model through a direct server plugin with
|
||||
the auth system.
|
||||
|
||||
|
||||
### recipe-author-name
|
||||
|
||||
Source: [talk-recipes/tree/master/plugins/author-name](https://github.com/coralproject/talk-recipes/tree/master/plugins/author-name){:target="_blank"}
|
||||
|
||||
Enables the ability to hover over a commenter’s name and add plugin
|
||||
functionality there. The Member Since plugin that is provided in this recipe is
|
||||
an example of this.
|
||||
@@ -0,0 +1,40 @@
|
||||
---
|
||||
title: How Talk Works
|
||||
permalink: /how-talk-works/
|
||||
---
|
||||
|
||||
Talk is an open-source commenting platform. It has two pieces. One is the
|
||||
embedded script, which allows newsrooms to have a unique comments section on
|
||||
each story/post/page they have on their site, and allows their readers to
|
||||
comment and discuss articles. The other is the Admin, which is where newsrooms
|
||||
moderate their comments and manage and configure Talk.
|
||||
|
||||
## Talk Core
|
||||
|
||||
As we’re building Talk, our vision was always to have it be very modular, so
|
||||
there are features we have built and are opinionated about, but we allow
|
||||
newsrooms and their developers to customize Talk easily so that it fits their
|
||||
use cases and needs.
|
||||
|
||||
Talk Core is the core application of Talk - this contains all of the standard
|
||||
commenting features that are necessary for a comment section, and ones that we
|
||||
believe are important to be universal. If you would like to contribute to Talk,
|
||||
be sure to check out our
|
||||
[Contributor's Guide](https://github.com/coralproject/talk/blob/master/CONTRIBUTING.md){:target="_blank"}.
|
||||
|
||||
## Plugins
|
||||
|
||||
Plugins are additional functionality which are optional to use with Talk. You
|
||||
can turn these on or off, depending on your specific needs. Plugins are either
|
||||
part of our core plugins, which ship with Talk, or they are developed by 3rd
|
||||
parties and either used privately and internally, or are open sourced for use
|
||||
across the greater community. You can explore the plugins we offer by visiting our [Default Plugins]({{ "/default-plugins/" | relative_url}})
|
||||
and [Additional Plugins]({{ "/additional-plugins/" | relative_url }}) pages.
|
||||
|
||||
## Recipes
|
||||
|
||||
Recipes are plugin templates that are created by the Talk team and 3rd party
|
||||
developers, in order to help contributors and newsrooms build plugins easily.
|
||||
You can explore the recipes we offer by visiting our [Plugin Recipes]({{ "/plugin-recipes/" | relative_url}})
|
||||
page.
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
---
|
||||
title: Trust
|
||||
permalink: /trust/
|
||||
---
|
||||
|
||||
Trust is a set of components within Talk that incorporate automated moderation
|
||||
features based on a user's previous behavior.
|
||||
|
||||
### User Karma Score
|
||||
|
||||
Using Trust’s calculations, Talk will automatically pre-moderate comments of
|
||||
users who have a negative karma score. All users start out with a `0` neutral
|
||||
karma score. If they have a comment approved by a moderator, their score
|
||||
increases by `1`; if they have a comment rejected by a moderator, it decreases
|
||||
by `1`. When a commenter is labeled as Unreliable, their comments must be
|
||||
moderated before they are posted.
|
||||
|
||||
When a commenter has one comment rejected, their next comment must be moderated
|
||||
once in order to post freely again. If they instead get rejected again, then
|
||||
they must have two of their comments approved in order to get added back to the
|
||||
queue.
|
||||
|
||||
Here are the default thresholds:
|
||||
|
||||
```text
|
||||
-2 and lower: Unreliable
|
||||
-1 to +2: Neutral
|
||||
+3 and higher: Reliable
|
||||
```
|
||||
|
||||
You can configure your own Trust thresholds by using [TRUST_THRESHOLD]({{"/advanced-configuration/#trust_thresholds" | relative_url }}{:.param}) in your
|
||||
configuration.
|
||||
|
||||
|
||||
### Reliable and Unreliable Flaggers
|
||||
|
||||
Trust also calculates how reliable users are in terms of the comments they
|
||||
report. This information is displayed to moderators in the User History drawer,
|
||||
which is accessed by clicking on a user’s name in the Admin.
|
||||
|
||||
If a user's reports mostly match what moderators reject, their Report status
|
||||
will display to moderators as Reliable in the user information drawer. If a
|
||||
user's reports mostly differ from what moderators reject, their Report status
|
||||
will show as Unreliable.
|
||||
|
||||
If we don't have enough reports to make a call, or the reports even out, their
|
||||
status is Neutral.
|
||||
|
||||
Note: Report Karma doesn't include reports of "I don't agree with this comment".
|
||||
@@ -0,0 +1,59 @@
|
||||
---
|
||||
title: Toxic Comments
|
||||
permalink: /toxic-comments/
|
||||
---
|
||||
|
||||
Leveraging Google's Perspective API, you can now set a Toxicity Threshold for
|
||||
Talk (0.8 or 80% is the default), which works like this:
|
||||
|
||||
- If a comment exceeds the threshold, the commenter is warned that their comment
|
||||
may be toxic, and are given the chance to modify their comment before posting
|
||||
- If the revised comment is below the Toxicity Threshold, it is posted and
|
||||
displayed normally
|
||||
- If the revised comment still exceeds the Toxicity Threshold, it is not
|
||||
displayed on the stream and instead is sent to the Reported queue for
|
||||
moderation
|
||||
- If the moderator accepts the comment, it's displayed on the stream; if it's
|
||||
rejected, it will not be displayed
|
||||
- Moderators see a Toxic Probability Score on toxic comments in the Moderation
|
||||
queues
|
||||
|
||||
Read more about Coral’s take on toxicity
|
||||
[on our blog](https://blog.coralproject.net/toxic-avenging/){:target="_blank"}.
|
||||
|
||||
### What is the Perspective API?
|
||||
|
||||
The likely toxicity of a comment is evaluated using scores generated from
|
||||
[Perspective API](http://perspectiveapi.com/){:target="_blank"}. This is part of
|
||||
the [Conversation AI](https://conversationai.github.io/){:target="_blank"}
|
||||
research effort run by Jigsaw (a section of Google that works on global problems
|
||||
around speech and access to information).
|
||||
|
||||
Perspective API uses machine learning, based on existing databases of
|
||||
accepted/rejected comments, to guess the probability that a comment is abusive
|
||||
and/or toxic. It is currently English only, but the system is designed to work
|
||||
with multiple languages.
|
||||
|
||||
In order to activate our plugin, each news organization applies for an API key
|
||||
from Jigsaw (click “Request API access” on this site.) Sites can also work with
|
||||
Jigsaw to create an individualized data set specifically trained on their own
|
||||
comment history.
|
||||
|
||||
Perspective API was released earlier this year, and is currently in alpha
|
||||
(meaning that it is being continually refined and improved.) Jigsaw should
|
||||
certainly be praised for devoting serious resources to this issue, and making
|
||||
their work available for others, including us, to use.
|
||||
|
||||
We’ve talked with their team on several occasions, and have been impressed by
|
||||
their dedication and commitment to this issue. These are smart people who are
|
||||
trying to improve a broken part of the internet.
|
||||
|
||||
### How do I add the Toxic Comments plugin?
|
||||
To enable this behavior, visit the
|
||||
[talk-plugin-toxic-comments]({{ "/additional-plugins/#talk-plugin-toxic-comments" | relative_url }})
|
||||
plugin documentation.
|
||||
|
||||
|
||||
### Request an API Key
|
||||
|
||||
You can read more about Google's Perspective API and/or request an API key here: [http://perspectiveapi.com/](http://perspectiveapi.com/).
|
||||
@@ -2,7 +2,7 @@
|
||||
layout: default
|
||||
---
|
||||
<article{% if page.class %} class="{{ page.class }}"{% endif %}>
|
||||
<a class="btn btn-sm btn-light float-right suggest-edits" href="https://github.com/coralproject/talk/edit/master/docs/{{ page.path }}" title="Suggest edits to this page">Suggest Edits</a>
|
||||
<a class="btn btn-sm btn-light float-right suggest-edits plain-link" href="https://github.com/coralproject/talk/edit/master/docs/{{ page.path }}" title="Suggest edits to this page">Suggest Edits</a>
|
||||
<h1>{{ page.title }}</h1>
|
||||
<hr/>
|
||||
|
||||
|
||||
+10
-4
@@ -36,13 +36,17 @@ h2, h3, h4 {
|
||||
}
|
||||
}
|
||||
|
||||
.param {
|
||||
font-family: Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
}
|
||||
|
||||
article.configuration {
|
||||
h2 {
|
||||
font-weight: bolder;
|
||||
}
|
||||
|
||||
h2, .toc, .param {
|
||||
font-family: Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
h2, .toc {
|
||||
@extend .param;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -227,10 +231,9 @@ pre {
|
||||
}
|
||||
|
||||
.coral-link {
|
||||
border-bottom: 2px dotted $coral_button_border_color;
|
||||
border-bottom: 1px solid $coral_button_border_color;
|
||||
color: $coral_button_color;
|
||||
text-decoration: none;
|
||||
font-weight: bold;
|
||||
|
||||
&:hover {
|
||||
color: $coral_button_hover_color;
|
||||
@@ -260,6 +263,9 @@ pre {
|
||||
p a:not(.plain-link) {
|
||||
@extend .coral-link;
|
||||
}
|
||||
ul:not(.toc__menu) li a {
|
||||
@extend .coral-link;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,20 +2,16 @@ const errors = require('../../errors');
|
||||
|
||||
const {
|
||||
UPDATE_SETTINGS,
|
||||
UPDATE_WORDLIST,
|
||||
} = require('../../perms/constants');
|
||||
|
||||
const SettingsService = require('../../services/settings');
|
||||
|
||||
const update = async (ctx, settings) => SettingsService.update(settings);
|
||||
|
||||
const updateWordlist = async (ctx, wordlist) => SettingsService.updateWordlist(wordlist);
|
||||
|
||||
module.exports = (ctx) => {
|
||||
let mutators = {
|
||||
Settings: {
|
||||
update: () => Promise.reject(errors.ErrNotAuthorized),
|
||||
updateWordlist: () => Promise.reject(errors.ErrNotAuthorized)
|
||||
}
|
||||
};
|
||||
|
||||
@@ -23,10 +19,6 @@ module.exports = (ctx) => {
|
||||
if (ctx.user.can(UPDATE_SETTINGS)) {
|
||||
mutators.Settings.update = (id, settings) => update(ctx, id, settings);
|
||||
}
|
||||
|
||||
if (ctx.user.can(UPDATE_WORDLIST)) {
|
||||
mutators.Settings.updateWordlist = (id, status) => updateWordlist(ctx, id, status);
|
||||
}
|
||||
}
|
||||
|
||||
return mutators;
|
||||
|
||||
@@ -61,9 +61,6 @@ const RootMutation = {
|
||||
updateSettings: async (_, {input: settings}, {mutators: {Settings}}) => {
|
||||
await Settings.update(settings);
|
||||
},
|
||||
updateWordlist: async (_, {input: wordlist}, {mutators: {Settings}}) => {
|
||||
await Settings.updateWordlist(wordlist);
|
||||
},
|
||||
createToken: async (_, {input}, {mutators: {Token}}) => ({
|
||||
token: await Token.create(input),
|
||||
}),
|
||||
|
||||
+12
-11
@@ -1217,6 +1217,12 @@ input UpdateSettingsInput {
|
||||
# editCommentWindowLength is the length of time (in milliseconds) after a
|
||||
# comment is posted that it can still be edited by the author.
|
||||
editCommentWindowLength: Int
|
||||
|
||||
# wordlist allows chaninging the available wordlists.
|
||||
wordlist: UpdateWordlistInput
|
||||
|
||||
# domains allows changing the available lists of domains.
|
||||
domains: UpdateDomainsInput
|
||||
}
|
||||
|
||||
# UpdateSettingsResponse contains any errors that were rendered as a result
|
||||
@@ -1231,18 +1237,17 @@ type UpdateSettingsResponse implements Response {
|
||||
input UpdateWordlistInput {
|
||||
|
||||
# banned words will by default reject the comment if it is found.
|
||||
banned: [String!]!
|
||||
banned: [String!]
|
||||
|
||||
# suspect words will simply flag the comment.
|
||||
suspect: [String!]!
|
||||
suspect: [String!]
|
||||
}
|
||||
|
||||
# UpdateWordlistResponse contains any errors that were rendered as a result
|
||||
# of the mutation.
|
||||
type UpdateWordlistResponse implements Response {
|
||||
# UpdateDomainsInput describes all the available lists of domains.
|
||||
input UpdateDomainsInput {
|
||||
|
||||
# An array of errors relating to the mutation that occurred.
|
||||
errors: [UserError!]
|
||||
# whitelist is the list of domains that the embed is allowed to render on.
|
||||
whitelist: [String!]
|
||||
}
|
||||
|
||||
# CreateTokenInput contains the input to create the token.
|
||||
@@ -1329,10 +1334,6 @@ type RootMutation {
|
||||
# Mutation is restricted.
|
||||
updateSettings(input: UpdateSettingsInput!): UpdateSettingsResponse
|
||||
|
||||
# updateWordlist will update the given Wordlist.
|
||||
# Mutation is restricted.
|
||||
updateWordlist(input: UpdateWordlistInput!): UpdateWordlistResponse
|
||||
|
||||
# Ignore comments by another user
|
||||
ignoreUser(id: ID!): IgnoreUserResponse
|
||||
|
||||
|
||||
+395
@@ -0,0 +1,395 @@
|
||||
da:
|
||||
your_account_has_been_suspended: "Din konto er midlertidigt suspenderet"
|
||||
your_account_has_been_banned: "Din konto er blevet banned."
|
||||
your_username_has_been_rejected: "Din konto er blevet suspenderet fordi dit brugernavn er blevet anset for upassende. For at gendanne din konto, venligst indtast et nyt brugernavn."
|
||||
embed_comments_tab: "Kommentarer"
|
||||
bandialog:
|
||||
are_you_sure: "Er du sikker på du vil banne {0}?"
|
||||
ban_user: "Ban Bruger?"
|
||||
banned_user: "Bannet Bruger"
|
||||
cancel: "Afbryd"
|
||||
note: "Note: {0}"
|
||||
note_reject_comment: "Hvis du banner denne bruger vil det også placere denne kommentar i den afviste kø"
|
||||
note_ban_user: "Hvis du banner denne bruger vil det forhindre dem i at redigere kommentarer eller fjerne noget"
|
||||
yes_ban_user: "Ja, Ban Bruger"
|
||||
bio_offensive: "Denne biografi er stødende"
|
||||
cancel: "Afbryd"
|
||||
characters_remaining: "tegn tilbage"
|
||||
comment:
|
||||
anon: "Anonym"
|
||||
ban_user: "Ban Bruger"
|
||||
comment: "Skriv en kommentar"
|
||||
edited: "Redigeret"
|
||||
flagged: "markeret"
|
||||
view_context: "Vis kontekst"
|
||||
comment_box:
|
||||
post: "Send"
|
||||
cancel: "Afbryd"
|
||||
reply: "Svar"
|
||||
comment: "Skriv en kommentar"
|
||||
name: "Navn"
|
||||
comment_post_notif: "Din kommentar er blevet sendt"
|
||||
comment_post_notif_premod: "Tak for at skrive en kommentar. Vores moderatorer vil gennemgå din kommentar inden for kort tid."
|
||||
comment_post_banned_word: "Din kommentar indeholder et eller flere ord, der ikke er tilladt, derfor vil den ikke bliver offentliggjort. Hvis du mener, at denne besked er forkert, bedes du kontakte vores moderatorer"
|
||||
characters_remaining: "tegn tilbage"
|
||||
comment_offensive: "Denne kommentar er stødende"
|
||||
comment_singular: "Kommentar"
|
||||
comment_plural: "Kommentarer"
|
||||
comment_post_banned_word: "Din kommentyar indeholder et eller flere ord, der ikke er tilladt, derfor vil den ikke bliver offentliggjort. Hvis du mener, at denne besked er forkert, bedes du kontakte vores moderatorer"
|
||||
comment_post_notif: "Din kommentar er blevet offentliggjort"
|
||||
comment_post_notif_premod: "Tak for at skrive en kommentar. Vores moderatorer vil gennemgå din kommentar inden for kort tid."
|
||||
common:
|
||||
copy: "Kopier"
|
||||
error: "Der er sket en fejl."
|
||||
reply: "svar"
|
||||
replies: "svar"
|
||||
reaction: "reaktion"
|
||||
reactions: "reaktioner"
|
||||
story: "Historie"
|
||||
community:
|
||||
account_creation_date: "Oprettelsedato for konto"
|
||||
active: "Aktive"
|
||||
admin: "Administrator"
|
||||
ads_marketing: "Dette ligner en annonce/markedsføring"
|
||||
are_you_sure: "Er du sikker på at du vil banne {0}?"
|
||||
ban_user: "Ban Bruger?"
|
||||
banned: "Bannet"
|
||||
banned_user: "Bannet Bruger"
|
||||
cancel: "Afbryd"
|
||||
dont_like_username: "Synses ikke om brugernavnet"
|
||||
flaggedaccounts: "Flagged Brugernavne"
|
||||
flags: "Flags"
|
||||
impersonating: "Personefterligning"
|
||||
loading: "Indlæser resultater"
|
||||
moderator: "Moderator"
|
||||
newsroom_role: "Nyhedsrum Rolle"
|
||||
no_flagged_accounts: "Køen for flagget kontoer er tom i øjeblikket"
|
||||
no_results: "Igen brugere fundet med det brugernavn eller email-adresse. De gemmer sig!"
|
||||
offensive: "Offensiv"
|
||||
other: "Andre"
|
||||
people: "Mennesker"
|
||||
role: "Vælg rolle..."
|
||||
select_status: "Vælg status..."
|
||||
spam_ads: "Spam/Reklamer"
|
||||
staff: "Personale"
|
||||
status: "Status"
|
||||
username_and_email: "Brugernavn eller email-adresse"
|
||||
yes_ban_user: "Ja Ban Bruger"
|
||||
configure:
|
||||
apply: "Anvend"
|
||||
banned_word_text: "Kommentarer, der indeholder disse ord eller sætninger (Ikke følsom over for store eller små bogstaver) fjernes automatisk fra kommentarstrømmen. Indtast et ord og tryk på Enter eller Tab for at tilføje. Indsæt eventuelt en kommasepareret liste."
|
||||
banned_words_title: "Bannet ord liste"
|
||||
close: "Luk"
|
||||
close_after: "Luk kommentarene efter"
|
||||
close_stream: "Luk strømmen"
|
||||
close_stream_configuration: "Denne kommentarstrøm er lukket i øjeblikket. Ved at åbne denne kommentarstrøm kan nye kommentarer indsendes og vises."
|
||||
closed_comments_desc: "Skriv en besked, der skal vises når, din kommentarstrøm er lukket og ikke længere accepterer kommentarer."
|
||||
closed_comments_label: "Skriv en besked..."
|
||||
closed_stream_settings: "Lukket strøm besked"
|
||||
comment_count_error: "Indtæst venligst et gyldigt numemr."
|
||||
comment_count_header: "Begræns kommentar længde"
|
||||
comment_count_text_post: "tegn"
|
||||
comment_count_text_pre: "Kommentarer vil være begrænset til"
|
||||
comment_settings: "Indstillinger"
|
||||
comment_stream: "Kommentarstrøm"
|
||||
comment_stream_will_close: "Denne kommentarstrøm vil blive lukkes"
|
||||
community: "Fællesskab"
|
||||
configure: "Konfigurer"
|
||||
copy_and_paste: "Kopier og indsæt kode nedenfor i dit CMS system for at indlejre din kommentarboks i dine artiker."
|
||||
custom_css_url: "Brugerdefineret CSS URL"
|
||||
custom_css_url_desc: "URL til et CSS stylesheet der tilsidesætter den alimdelige strøms stilart. Kan være internt eller externt"
|
||||
dashboard: "Instrumentbræt"
|
||||
days: "Dage"
|
||||
description: "Som administrator kan du tilpasse indstillingerne til kommentarstrømmen for denne historie:"
|
||||
domain_list_text: "Indtast de domæner du gerne vil tillade til Talk. f.eks. Dit lokale udviklings miljø (fx. localhost:3000 staging.domain.com domain.com)."
|
||||
domain_list_title: "Tilladte domæner."
|
||||
edit_comment_timeframe_heading: "Rediger kommentar tidsramme"
|
||||
edit_comment_timeframe_text_pre: "Kommentatorer vil have"
|
||||
edit_comment_timeframe_text_post: "sekunder for at redigere deres kommentarer."
|
||||
embed_comment_stream: "Indlejre strøm"
|
||||
enable_premod_links_text: "Moderatorer skal godkende enhver kommentar, der indeholder et link før det bliver offentliggjort."
|
||||
enable_pre_moderation: "Aktiver forud moderering"
|
||||
enable_pre_moderation_text: "Moderatorer skal godkende enhver kommentar før den offentliggøres."
|
||||
enable_premod_links: "Forud moderer kommentarer der indeholder links"
|
||||
enable_premod: "Aktiver forud moderering"
|
||||
enable_premod_description: "Moderatorer skal godkende enhver kommentar før den offentliggøres"
|
||||
enable_premod_links_description: "Moderatorer skal godkende enhver kommentar, der indeholder et link før det bliver offentliggjort."
|
||||
enable_questionbox: "Stil et spørgsmål til læserne"
|
||||
enable_questionbox_description: "Dette spørgsmål vises øverst i denne kommentarstrøm. Stil et spørgsmål til læserne om et bestemt problem i artiklen eller stil spørgsmål til diskussion mv."
|
||||
hours: "Timer"
|
||||
include_comment_stream: "Inkluder kommentarstrømmens beskrivelse for læsere"
|
||||
include_comment_stream_desc: "Skriv en besked, der skal tilføjes øverst i din kommentarstrøm. Indsæt et emne der omfatter fællesskabsretningslinjer mv."
|
||||
include_text: "Medtag din tekst her."
|
||||
include_question_here: "Stil dit spørgsmål her:"
|
||||
moderate: "Moderat"
|
||||
moderation_settings: "Moderator indstillinger"
|
||||
open: "Åben"
|
||||
open_stream: "Åben strøm"
|
||||
open_stream_configuration: "Denne kommentarstrøm er i øjeblikket åben. Ved at lukke denne kommentarstrøm kan der ikke indsendes nye kommentarer, og alle tidligere kommentarer vil stadig blive vist."
|
||||
require_email_verification: "Kræv email verifikation"
|
||||
require_email_verification_text: "Nye brugere skal verificere deres email før de kan kommenterer"
|
||||
save_changes: "Gem ændringer"
|
||||
shortcuts: "Genveje"
|
||||
sign_out: "Log ud"
|
||||
stories: "Historier"
|
||||
stream_settings: "Strøm indstillinger"
|
||||
suspect_word_title: "Mistænkte ord liste"
|
||||
suspect_word_text: "Kommentarer der indeholder disse ord eller sætninger (Ikke følsom over for store eller små bogstaver) vil blive fremhævet i kommentarstrømmen. Indtast et ord, og tryk på Enter eller Tab for at tilføje. Indsæt eventuelt en kommasepareret liste."
|
||||
tech_settings: "Tekniske indstillinger"
|
||||
title: "Konfigurer kommentarstrøm"
|
||||
weeks: "Uger"
|
||||
wordlist: "Forbudte ord"
|
||||
continue: "Fortsæt"
|
||||
createdisplay:
|
||||
check_the_form: "Ugyldig formular. Tjek venligst felterne"
|
||||
continue: "Fprtsæt med det samme Facebook brugernavn"
|
||||
error_create: "Fejl ved ændring af brugernavn"
|
||||
fake_comment_body: "Dette er et eksempel på en kommentar. Læsere kan dele deres tanker og meninger med nyhedsrummet i kommentarfeltet"
|
||||
fake_comment_date: "1 minut siden"
|
||||
if_you_dont_change_your_name: "Hvis du ikke ændrer dit brugernavn på dette trin, vil dit Facebook brugernavn vises sammen med alle dine kommentarer"
|
||||
required_field: "Påkrævet felt"
|
||||
save: "Gem"
|
||||
special_characters: "Brugernavne kan kun indeholde bogstaver og _"
|
||||
username: "Brugernavn"
|
||||
write_your_username: "Rediger dit brugernavn"
|
||||
your_username: "Dit brugernavn vises på hver kommentar du sender."
|
||||
dashboard:
|
||||
auto_update: "Data opdateres automatisk hvert femte minut, eller når du genindlæser."
|
||||
comment_count: "kommentarer"
|
||||
flags: "Flags"
|
||||
most_flags: "Historier med de fleste flags"
|
||||
most_conversations: "Historier med de fleste samtaler"
|
||||
next_update: "{0} minutter indtil næste opdatering."
|
||||
no_activity: "Der har ikke været nogen kommentarer i løbet af de sidste fem minutter."
|
||||
no_flags: "Der har ikke været nogle flag i de sidste 5 minutter! Hurra!"
|
||||
no_likes: "Der har ikke været nogen der syntes godt om noget i de sidste 5 minutter. Helt stille"
|
||||
done: "Færdig"
|
||||
edit_comment:
|
||||
body_input_label: "Rediger denne kommentar"
|
||||
save_button: "Gem ændringer"
|
||||
edit_window_expired: "Du kan ikke længere redigere denne kommentar. Tidsvinduet for at gøre det er udløbet. Hvorfor skriver du ikke en ny?"
|
||||
edit_window_expired_close: "Luk"
|
||||
edit_window_timer_prefix: "Luk vindue: "
|
||||
second: "sekund"
|
||||
seconds_plural: "sekunder"
|
||||
email:
|
||||
confirm:
|
||||
has_been_requested: "Der er anmodet om en e-mail bekræftelse for følgende konto:"
|
||||
to_confirm: "For at bekræfte kontoen, skal du besøge følgende link:"
|
||||
confirm_email: "Bekræft Email"
|
||||
if_you_did_not: "Hvis du ikke har anmodet om dette, kan du med sikkerhed ignorere denne email."
|
||||
subject: "Email Bekræftelse"
|
||||
password_reset:
|
||||
we_received_a_request: "Vi har modtaget en anmodning om at nulstille din adgangskode. Hvis du ikke har anmodet om denne ændring, kan du ignoere denne email."
|
||||
if_you_did: "Hvis du gjorde,"
|
||||
please_click: "venligst klik her for at nulstille adgangskoden"
|
||||
embedlink:
|
||||
copy: "Kopier til udklipsholder"
|
||||
error:
|
||||
COMMENT_TOO_SHORT: "Din kommentar skal indeholde noget"
|
||||
NOT_AUTHORIZED: "Du har ikke tilladelse til at udføre denne handling."
|
||||
NO_SPECIAL_CHARACTERS: "Brugernavne kan kun indeholder bogstaver og _"
|
||||
PASSWORD_LENGTH: "Adgangskoden er for kort"
|
||||
PROFANITY_ERROR: "Brugernavne må ikke inholde stødende indhold. Kontakt venligst administratoren, hvis du mener at dette er en fejl."
|
||||
USERNAME_IN_USE: "Brugernavnet er allerede i brug"
|
||||
USERNAME_REQUIRED: "Du skal indtaste et brugernavn"
|
||||
EDIT_WINDOW_ENDED: "Du kan ikke længere redigere denne kommentar. Tidsvinduet for at gøre det er udløbet."
|
||||
EDIT_USERNAME_NOT_AUTHORIZED: "Du har ikke tilladelse til at opdatere dit brugernavn."
|
||||
SAME_USERNAME_PROVIDED: "Du skal indsende et andet brugernavn."
|
||||
EMAIL_IN_USE: "Email adressen er allerede i brug"
|
||||
EMAIL_REQUIRED: "En email adresse er påkrævet"
|
||||
LOGIN_MAXIMUM_EXCEEDED: "Du har lavet for mange mislykkede adgangskodeforsøg. Vent venligst."
|
||||
PASSWORD_REQUIRED: "Du skal indtaste et kodeord"
|
||||
COMMENTING_CLOSED: "Kommentering er allerede lukket"
|
||||
NOT_FOUND: "Ressource ikke fundet"
|
||||
ALREADY_EXISTS: "Ressource eksisterer allerede"
|
||||
INVALID_ASSET_URL: "Aktivitetswebadresse er ugyldig"
|
||||
email: "Ikke en gylding e-mail"
|
||||
confirm_password: "Kodeordene matcher ikke. Tjek venligst igen."
|
||||
network_error: "Det lykkedes ikke at oprette forbindelse til serveren. Tjek din internetforbindelse og prøv igen."
|
||||
email_not_verified: "E-mail adresse {0} ikke bekræftet."
|
||||
email_password: "E-mail og/eller kodeord er forkert."
|
||||
organization_name: "Organisationsnavn må kun indeholde bogstaver og tal."
|
||||
password: "Adgangskoden skal være mindst 8 tegn"
|
||||
username: "Brugernavne kan kun indeholde bogstaver og _"
|
||||
unexpected: "Der opstod en uventet fejl. Undskyld!"
|
||||
flag_comment: "Rapportér kommentar"
|
||||
flag_reason: "Årsag til rapportering (Valgfrit)"
|
||||
flag_username: "Rapporter brugernavn"
|
||||
framework:
|
||||
banned_account_header: "Din konto er i øjeblikket bannet"
|
||||
banned_account_body: "Det betyder at du ikke kan synes godt om, rapportere eller skrive kommentarer."
|
||||
comment: "kommentar"
|
||||
comment_is_ignored: "Denne kommentar er skjult, fordi du ignoerede denne bruger."
|
||||
comment_is_rejected: "Du har afvist denne kommentar."
|
||||
comment_is_hidden: "Denne kommentar er ikke tilgængelig"
|
||||
comments: "kommentarer"
|
||||
configure_stream: "Konfigurer"
|
||||
content_not_available: "Dette indhold er ikke tilgængeligt"
|
||||
edit_name:
|
||||
button: "Indsend"
|
||||
error: "Brugernavne kan kun indeholde bogstaver og _"
|
||||
label: "Nyt brugernavn"
|
||||
msg: "Din konto er midlertidigt suspenderet, fordi dit brugernavn er blevet anset for upassende. For at gendanne din konto skal du indtaste et nyt brugernavn. Kontakt os venligst, hvis du har spørgsmål."
|
||||
my_comments: "Mine kommentarer"
|
||||
my_profile: "Min profil"
|
||||
new_count: "Se {0} mere {1}"
|
||||
profile: "Profil"
|
||||
show_all_comments: "Vis alle kommentarer"
|
||||
success_bio_update: "Din biografi er blevet opdateret"
|
||||
success_name_update: "Dit brugernavn er blevet opdateret"
|
||||
success_update_settings: "De ændringer du har foretaget, er blevet anvendt til kommentarstrømmen på denne artikel."
|
||||
show_all_replies: "Vis alle svar"
|
||||
show_more_replies: "Vis flere svar"
|
||||
view_more_comments: "se flere kommentarer"
|
||||
view_reply: "vis svar"
|
||||
from_settings_page: "Fra profilsiden kan du se din kommentarhistorie."
|
||||
like: "Synes godt om"
|
||||
loading_results: "Indlæser resultater"
|
||||
marketing: "Dette ligner en annonce/markedsføring"
|
||||
moderate_this_stream: "Moderer denne strøm"
|
||||
modqueue:
|
||||
account: "konto flag"
|
||||
actions: "Handlinger"
|
||||
all: "alle"
|
||||
all_streams: "Alle Strømme"
|
||||
notify_edited: "{0} redigeret kommentar {1}"
|
||||
notify_accepted: "{0} accepteret kommentar {1}"
|
||||
notify_rejected: "{0} afvist kommentar {1}"
|
||||
notify_flagged: "{0} flagget kommentar {1}"
|
||||
approve: "Godkend"
|
||||
approved: "Godkendt"
|
||||
ban_user: "Ban"
|
||||
billion: "M"
|
||||
close: "Close"
|
||||
empty_queue: "Ikke flere kommentarer til at moderer! Du har indhentet det hele. Gå ud og tag noget ☕️"
|
||||
flagged: "flagged"
|
||||
reported: "Rapporteret"
|
||||
less_detail: "Færre detajler"
|
||||
likes: "Syntes godt om"
|
||||
million: "M"
|
||||
mod_faster: "Moderer hurtigere med tastaturgenveje"
|
||||
moderate: "Moderer →"
|
||||
more_detail: "Flere detaljer"
|
||||
new: "Ny"
|
||||
newest_first: "Nyeste først"
|
||||
navigation: "Navigation"
|
||||
next_comment: "Gå til næste kommentar"
|
||||
oldest_first: "Ældste Først"
|
||||
premod: "pre-mod"
|
||||
prev_comment: "Gå til den forrige kommentar"
|
||||
reject: "Afvis"
|
||||
rejected: "Afvist"
|
||||
reply: "Svar"
|
||||
select_stream: "Vælg strøm"
|
||||
shift_key: "⇧"
|
||||
shortcuts: "Genveje"
|
||||
show_shortcuts: "Vis genveje"
|
||||
singleview: "Skift enkeltkommentar redigerings visning"
|
||||
thismenu: "Åben denne menu"
|
||||
thousand: "k"
|
||||
try_these: "Prøv disse"
|
||||
view_more_shortcuts: "Se flere genveje"
|
||||
my_comment_history: "Min kommentar Historie"
|
||||
name: "Navn"
|
||||
no_agree_comment: "Jeg er ikke enig med denne kommentar"
|
||||
no_like_bio: "Jeg kan ikke lide denne biografi"
|
||||
no_like_username: "Jeg kan ikke lide dette brugernavn"
|
||||
other: "Andet"
|
||||
permalink: "Link"
|
||||
personal_info: "Denne kommentar afslører personligt identificerbare oplysninger"
|
||||
post: "Post"
|
||||
profile: "Profil"
|
||||
profile_settings: "Profilindstillinger"
|
||||
reply: "Svar"
|
||||
report: "Rapporter"
|
||||
report_notif: "Tak fordi du rapporterede denne kommentar. Vores modereringsteam har fået besked, og vil gennemgå det inden for kort tid."
|
||||
report_notif_remove: "Din rapport er blevet fjernet"
|
||||
reported: "Rapporteret"
|
||||
settings:
|
||||
from_settings_page: "Fra profilsiden kan du se din kommentarhistorie."
|
||||
my_comment_history: "Min kommentarhistorie"
|
||||
profile: "Profil"
|
||||
profile_settings: "Profilindstillinger"
|
||||
sign_in: "Log ind"
|
||||
to_access: "for at tilgå din profil"
|
||||
user_no_comment: "Du har aldrig efterladt en kommentar. Deltag i samtalen!"
|
||||
stream:
|
||||
all_comments: "Alle kommentarer"
|
||||
temporarily_suspended: "I overensstemmelse med {0}'s retningslinjer for fællesskabet er din konto midlertidigt suspenderet. Venligst tilslut dig samtalen {1}."
|
||||
comment_not_found: "Kommentar blev ikke fundet"
|
||||
step_1_header: "Rapportér et problem"
|
||||
step_2_header: "Hjælp os med at forstå"
|
||||
step_3_header: "Tak for din indsats"
|
||||
streams:
|
||||
all: "Alle"
|
||||
article: "Historie"
|
||||
closed: "Lukket"
|
||||
empty_result: "Ingen egenskaber matcher denne søgning. Måske prøv og forsøge at udvide din søgning?"
|
||||
filter_streams: "Filtrer strømme"
|
||||
newest: "Nyeste"
|
||||
oldest: "Ældste"
|
||||
open: "Åben"
|
||||
pubdate: "Udgivelsedato"
|
||||
search: "Søg"
|
||||
sort_by: "Sorter efter"
|
||||
status: "Strøm status"
|
||||
stream_status: "Strøm Status"
|
||||
suspenduser:
|
||||
title_suspend: "Suspendere bruger"
|
||||
description_suspend: "Du suspenderer {0}. Denne kommentar vil gå til den afviste kø, og {0} vil ikke kunne synes godt om, rapportere, svare eller indsende indtil suspenderings tiden er færdig."
|
||||
select_duration: "Vælg suspenderings tid"
|
||||
one_hour: "1 time"
|
||||
hours: "{0} timer"
|
||||
days: "{0} dage"
|
||||
cancel: "Afbryd"
|
||||
suspend_user: "Suspender bruger"
|
||||
email_message_suspend: "Kære {0},\n\nI overensstemmelse med {1}'s retningslinjer for fællesskabet er din konto midlertidigt suspenderet. Under suspensionen vil du ikke kunne kommentere, flagge eller engagere dig med andre kommentarer. Venligst tilslut dig til samtalen{2}.'"
|
||||
title_notify: "Underret bruygeren om deres midlertidige suspension"
|
||||
notify_suspend_until: "Bruger {0} er midlertidigt suspenderet. Denne suspension ophører automatisk om {1}."
|
||||
description_notify: "Suspendering af denne bruger vil midlertidigt deaktivere deres konto og skjule alle deres kommentarer på webstedet."
|
||||
write_message: "Skriv en besked"
|
||||
send: "Send"
|
||||
reject_username:
|
||||
username: "brugernavn"
|
||||
no_cancel: "Nej Afbryd"
|
||||
description_reject: "Vil du midlertidigt forbyde denne bruger på grund af deres {0}? Hvis du gør det, vil det midlertidigt skjule deres kommentarer, indtil de omskriver deres {0}."
|
||||
title_notify: "Underret brugeren om deres midlertidige suspension"
|
||||
description_notify: "Suspendering af denne bruger vil midlertidigt deaktivere deres konto, og skjule alle deres kommentarer på webstedet."
|
||||
title_reject: "Vi har bemærket at du afviste et brugernavn."
|
||||
suspend_user: "Suspender bruger"
|
||||
yes_suspend: "Ja suspender"
|
||||
email_message_reject: "Et andet medlem af samfundet har for nylig flagget dit brugernavn til anmeldelse. På grund af indholdet blev din bruger afvist. Det betyder, at du ikke længere kan kommentere, synes godt om eller flagge indhold, før du omskriver dit brugernavn. Venligst send os en e-mail, hvis du har spørgsmål eller bekymringer."
|
||||
write_message: "Skriv en besked"
|
||||
send: "Send"
|
||||
thank_you: "Vi sætter pris på din sikkerhed og feedback. En moderator vil gennemgå din rapport"
|
||||
user:
|
||||
bio_flags: "flag for denne bio"
|
||||
user_bio: "Bruger Biografi"
|
||||
username_flags: "flag for dette brugernavn"
|
||||
user_impersonating: "Denne bruger efterligner"
|
||||
user_no_comment: "Du har aldrig efterladt en kommentar. Deltag i samtalen"
|
||||
username_offensive: "Dette brugernavn er stødende"
|
||||
view_conversation: "Vis samtale"
|
||||
install:
|
||||
initial:
|
||||
description: "Lad os oprette dit Talk-fællesskab på bare nogle få korte trin"
|
||||
submit: "Kom igang"
|
||||
add_organization:
|
||||
description: "Fortæl os navnet på din organisation. Dette vises i emails, når du inviterer nye teammedlemmer."
|
||||
label: "Organisationens navn"
|
||||
save: "Gem"
|
||||
create:
|
||||
email: "Email adresse"
|
||||
username: "Brugernavn"
|
||||
password: "Kodeord"
|
||||
confirm_password: "Bekræft kodeord"
|
||||
save: "Gem"
|
||||
permitted_domains:
|
||||
title: "Tilladte domæner"
|
||||
description: "Indtast de domæner, du gerne vil tillade til Talk. f.eks. dine lokale, og produktionsmiljøer. (fx. localhost:3000, staging.domain.com, domain.com)."
|
||||
submit: "Afslut installationen"
|
||||
final:
|
||||
description: "Tak fordi du installerede Talk! Vi sendte en email for at bekræfte din e-mail-adresse. Mens du færdigøre oprettelsen af kontoen, kan du begynde at engagere med dine læsere."
|
||||
launch: "Lancere Talk"
|
||||
close: "Luk dette installations program"
|
||||
+2
-2
@@ -57,13 +57,13 @@ en:
|
||||
banned_user: "Banned User"
|
||||
cancel: Cancel
|
||||
dont_like_username: "Dislike username"
|
||||
flaggedaccounts: "Flagged Usernames"
|
||||
flaggedaccounts: "Reported Usernames"
|
||||
flags: Flags
|
||||
impersonating: "Impersonation"
|
||||
loading: "Loading results"
|
||||
moderator: Moderator
|
||||
newsroom_role: "Newsroom Role"
|
||||
no_flagged_accounts: "The Flagged Usernames queue is currently empty."
|
||||
no_flagged_accounts: "The Reported Usernames queue is currently empty."
|
||||
no_results: "No users found with that user name or email address. They're hiding!"
|
||||
offensive: "Offensive"
|
||||
other: Other
|
||||
|
||||
+11
-6
@@ -12,13 +12,15 @@
|
||||
"build": "WEBPACK=TRUE NODE_ENV=production webpack -p --config webpack.config.js --bail",
|
||||
"prebuild-watch": "yarn generate-introspection",
|
||||
"build-watch": "WEBPACK=TRUE NODE_ENV=development webpack --progress --config webpack.config.js --watch",
|
||||
"lint": "eslint --ext=.js --ext=.json bin/* .",
|
||||
"lint": "yamllint locales/*.yml && eslint --ext=.js --ext=.json bin/* .",
|
||||
"lint-fix": "yarn lint --fix",
|
||||
"jest-watch": "TEST_MODE=unit NODE_ENV=test jest --watch",
|
||||
"test": "TEST_MODE=unit NODE_ENV=test jest && TEST_MODE=unit NODE_ENV=test mocha -R ${MOCHA_REPORTER:-spec}",
|
||||
"test-cover": "TEST_MODE=unit NODE_ENV=test istanbul cover _mocha --report text --check-coverage -- -R spec",
|
||||
"heroku-postbuild": "./bin/cli plugins reconcile && yarn build",
|
||||
"generate-introspection": "WEBPACK=TRUE NODE_ENV=test ./scripts/generateIntrospectionResult.js"
|
||||
"generate-introspection": "WEBPACK=TRUE NODE_ENV=test ./scripts/generateIntrospectionResult.js",
|
||||
"snyk-protect": "snyk protect",
|
||||
"prepublish": "npm run snyk-protect"
|
||||
},
|
||||
"talk": {
|
||||
"migration": {
|
||||
@@ -177,13 +179,14 @@
|
||||
"timekeeper": "^1.0.0",
|
||||
"tlds": "^1.196.0",
|
||||
"url-join": "^2.0.2",
|
||||
"url-loader": "^0.5.9",
|
||||
"url-loader": "^0.6.0",
|
||||
"url-search-params": "^0.9.0",
|
||||
"uuid": "^3.1.0",
|
||||
"webpack": "^2.3.1",
|
||||
"webpack-sources": "^1.0.1",
|
||||
"yaml-loader": "^0.4.0",
|
||||
"yamljs": "^0.2.10"
|
||||
"yamljs": "^0.2.10",
|
||||
"snyk": "^1.42.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@coralproject/eslint-config-talk": "^0.0.4",
|
||||
@@ -204,12 +207,14 @@
|
||||
"nodemon": "^1.11.0",
|
||||
"pre-git": "^3.15.3",
|
||||
"sinon": "^3.2.1",
|
||||
"sinon-chai": "^2.13.0"
|
||||
"sinon-chai": "^2.13.0",
|
||||
"yaml-lint": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^8"
|
||||
},
|
||||
"release": {
|
||||
"analyzeCommits": "simple-commit-message"
|
||||
}
|
||||
},
|
||||
"snyk": true
|
||||
}
|
||||
|
||||
@@ -19,7 +19,6 @@ module.exports = {
|
||||
UPDATE_ASSET_SETTINGS: 'UPDATE_ASSET_SETTINGS',
|
||||
UPDATE_ASSET_STATUS: 'UPDATE_ASSET_STATUS',
|
||||
UPDATE_SETTINGS: 'UPDATE_SETTINGS',
|
||||
UPDATE_WORDLIST: 'UPDATE_WORDLIST',
|
||||
|
||||
// queries
|
||||
SEARCH_ASSETS: 'SEARCH_ASSETS',
|
||||
|
||||
@@ -19,7 +19,6 @@ module.exports = (user, perm) => {
|
||||
case types.SET_COMMENT_STATUS:
|
||||
case types.UPDATE_CONFIG:
|
||||
case types.UPDATE_SETTINGS:
|
||||
case types.UPDATE_WORDLIST:
|
||||
case types.UPDATE_ASSET_SETTINGS:
|
||||
case types.UPDATE_ASSET_STATUS:
|
||||
return check(user, ['ADMIN', 'MODERATOR']);
|
||||
|
||||
@@ -6,3 +6,4 @@ export {default as CommentAuthorName} from 'coral-framework/components/CommentAu
|
||||
export {default as CommentTimestamp} from 'coral-framework/components/CommentTimestamp';
|
||||
export {default as CommentDetail} from 'coral-framework/components/CommentDetail';
|
||||
export {default as CommentContent} from 'coral-framework/components/CommentContent';
|
||||
export {default as ConfigureCard} from 'coral-framework/components/ConfigureCard';
|
||||
|
||||
+14
-14
@@ -1,27 +1,27 @@
|
||||
{
|
||||
"server": [
|
||||
"talk-plugin-auth",
|
||||
"talk-plugin-respect",
|
||||
"talk-plugin-offtopic",
|
||||
"talk-plugin-facebook-auth",
|
||||
"talk-plugin-featured-comments"
|
||||
"talk-plugin-featured-comments",
|
||||
"talk-plugin-offtopic",
|
||||
"talk-plugin-respect"
|
||||
],
|
||||
"client": [
|
||||
"talk-plugin-respect",
|
||||
"talk-plugin-auth",
|
||||
"talk-plugin-offtopic",
|
||||
"talk-plugin-viewing-options",
|
||||
"talk-plugin-author-menu",
|
||||
"talk-plugin-comment-content",
|
||||
"talk-plugin-permalink",
|
||||
"talk-plugin-featured-comments",
|
||||
"talk-plugin-flag-details",
|
||||
"talk-plugin-ignore-user",
|
||||
"talk-plugin-member-since",
|
||||
"talk-plugin-moderation-actions",
|
||||
"talk-plugin-offtopic",
|
||||
"talk-plugin-permalink",
|
||||
"talk-plugin-respect",
|
||||
"talk-plugin-sort-most-replied",
|
||||
"talk-plugin-sort-most-respected",
|
||||
"talk-plugin-sort-newest",
|
||||
"talk-plugin-sort-oldest",
|
||||
"talk-plugin-sort-most-respected",
|
||||
"talk-plugin-sort-most-replied",
|
||||
"talk-plugin-author-menu",
|
||||
"talk-plugin-member-since",
|
||||
"talk-plugin-ignore-user",
|
||||
"talk-plugin-moderation-actions",
|
||||
"talk-plugin-flag-details"
|
||||
"talk-plugin-viewing-options"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -5,5 +5,8 @@
|
||||
"description": "Provides support for measuring the toxicity of user comments using the Perspectives API",
|
||||
"main": "index.js",
|
||||
"author": "The Coral Project Team <coral@mozillafoundation.org>",
|
||||
"license": "Apache-2.0"
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"ms": "^2.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
const ms = require('ms');
|
||||
|
||||
const config = {
|
||||
API_ENDPOINT: process.env.TALK_PERSPECTIVE_API_ENDPOINT || 'https://commentanalyzer.googleapis.com/v1alpha1',
|
||||
API_KEY: process.env.TALK_PERSPECTIVE_API_KEY,
|
||||
THRESHOLD: process.env.TALK_TOXICITY_THRESHOLD || 0.8,
|
||||
API_TIMEOUT: process.env.TALK_PERSPECTIVE_TIMEOUT || 300,
|
||||
API_TIMEOUT: ms(process.env.TALK_PERSPECTIVE_TIMEOUT || '300ms'),
|
||||
};
|
||||
|
||||
if (process.env.NODE_ENV !== 'test' && !config.API_KEY) {
|
||||
|
||||
@@ -2,3 +2,6 @@
|
||||
# yarn lockfile v1
|
||||
|
||||
|
||||
ms@^2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8"
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 6.2 KiB |
+1
-1
@@ -116,7 +116,7 @@ module.exports = class AssetsService {
|
||||
static search({value, limit, open, sortOrder, cursor} = {}) {
|
||||
let assets = AssetModel.find({});
|
||||
|
||||
if (value) {
|
||||
if (value && value.length > 0) {
|
||||
assets.merge({
|
||||
$text: {
|
||||
$search: value
|
||||
|
||||
+3
-2
@@ -3,6 +3,7 @@ const get = require('lodash/get');
|
||||
|
||||
const yaml = require('yamljs');
|
||||
|
||||
const da = yaml.load('./locales/da.yml');
|
||||
const es = yaml.load('./locales/es.yml');
|
||||
const en = yaml.load('./locales/en.yml');
|
||||
const fr = yaml.load('./locales/fr.yml');
|
||||
@@ -13,9 +14,9 @@ const accepts = require('accepts');
|
||||
// default language
|
||||
let defaultLanguage = 'en';
|
||||
let language = defaultLanguage;
|
||||
const languages = ['en', 'es', 'fr', 'pt_BR'];
|
||||
const languages = ['en', 'da', 'es', 'fr', 'pt_BR'];
|
||||
|
||||
const translations = Object.assign(en, es, fr, pt_BR);
|
||||
const translations = Object.assign(en, es, fr, pt_BR, da);
|
||||
|
||||
/**
|
||||
* Exposes a service object to allow translations.
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Escape string for special regular expression characters.
|
||||
*/
|
||||
function escapeRegExp(string) {
|
||||
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
escapeRegExp,
|
||||
};
|
||||
+27
-14
@@ -1,6 +1,32 @@
|
||||
const SettingModel = require('../models/setting');
|
||||
const errors = require('../errors');
|
||||
|
||||
function dotizeRecurse(result, object, path = '') {
|
||||
for (const key in object) {
|
||||
const newPath = path ? `${path}.${key}` : key;
|
||||
if (typeof object[key] === 'object' && !Array.isArray(object[key])) {
|
||||
dotizeRecurse(result, object[key], newPath);
|
||||
continue;
|
||||
}
|
||||
result[newPath] = object[key];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dotize turns a nested object into flattened object with
|
||||
* dotized key notation. Arrays do not become dotized.
|
||||
*
|
||||
* e.g. {a: {b: 'c'}} becomes {'a.b': 'c}
|
||||
*
|
||||
* @param {Object} object
|
||||
* @return {Object} dotized object
|
||||
*/
|
||||
function dotize(object) {
|
||||
const result = {};
|
||||
dotizeRecurse(result, object);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* The selector used to uniquely identify the settings document.
|
||||
*/
|
||||
@@ -34,7 +60,7 @@ module.exports = class SettingsService {
|
||||
*/
|
||||
static update(settings) {
|
||||
return SettingModel.findOneAndUpdate(selector, {
|
||||
$set: settings
|
||||
$set: dotize(settings)
|
||||
}, {
|
||||
upsert: true,
|
||||
new: true,
|
||||
@@ -42,19 +68,6 @@ module.exports = class SettingsService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* updateWordlist will update the wordlists.
|
||||
*
|
||||
* @param {Object} wordlist the Wordlist object
|
||||
*/
|
||||
static updateWordlist(wordlist) {
|
||||
return SettingModel.findOneAndUpdate(selector, {
|
||||
$set: {
|
||||
wordlist,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* This is run once when the app starts to ensure settings are populated.
|
||||
*/
|
||||
|
||||
+10
-4
@@ -24,6 +24,7 @@ const ActionsService = require('./actions');
|
||||
const MailerService = require('./mailer');
|
||||
const Wordlist = require('./wordlist');
|
||||
const Domainlist = require('./domainlist');
|
||||
const {escapeRegExp} = require('./regex');
|
||||
|
||||
const EMAIL_CONFIRM_JWT_SUBJECT = 'email_confirm';
|
||||
const PASSWORD_RESET_JWT_SUBJECT = 'password_reset';
|
||||
@@ -630,14 +631,19 @@ module.exports = class UsersService {
|
||||
* @return {Promise}
|
||||
*/
|
||||
static search(value) {
|
||||
if (!value || typeof value !== 'string' || value.length === 0) {
|
||||
return UserModel.find({});
|
||||
}
|
||||
|
||||
value = escapeRegExp(value);
|
||||
|
||||
return UserModel.find({
|
||||
$or: [
|
||||
|
||||
// Search by a prefix match on the username.
|
||||
{
|
||||
'username': {
|
||||
$regex: new RegExp(`^${value}`),
|
||||
$options: 'i'
|
||||
'lowercaseUsername': {
|
||||
$regex: new RegExp(value.toLowerCase())
|
||||
}
|
||||
},
|
||||
|
||||
@@ -646,7 +652,7 @@ module.exports = class UsersService {
|
||||
'profiles': {
|
||||
$elemMatch: {
|
||||
id: {
|
||||
$regex: new RegExp(`^${value}`),
|
||||
$regex: new RegExp(value),
|
||||
$options: 'i'
|
||||
},
|
||||
provider: 'local'
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user