Merge branch 'terms-and-conditions' of github.com:coralproject/talk into terms-and-conditions

* 'terms-and-conditions' of github.com:coralproject/talk: (136 commits)
  Remaining rename
  Give descriptive key for slot elements and use it instead of talkPluginName
  Add back missing tutorial
  removed unused plugin from directory
  expanded fix to staff replies
  Fix notification responsiveness
  Profile my comments responsive fixes
  Notification fixes
  Remove italic styling from blockquote
  Better disabled detection
  Disable hover on touch devices
  docs update
  Let browser insert text
  Support nested feature instance
  Rename buttons to feature
  Add classNames for styling
  Shortcut support
  Fix append new line after node..
  Better selection behavior
  Remove all unwanted style attr
  ...
This commit is contained in:
okbel
2018-03-30 10:36:33 -03:00
178 changed files with 4724 additions and 10481 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"presets": [
["es2015", {modules: false}]
["es2015", {"modules": false}]
],
"plugins": [
"transform-class-properties",
+5 -1
View File
@@ -6,6 +6,7 @@ npm-debug.log*
dump.rdb
client/coral-framework/graphql/introspection.json
docs/source/_data/introspection.json
.env
*.cfg
@@ -30,6 +31,7 @@ plugins/*
!plugins/talk-plugin-author-menu
!plugins/talk-plugin-comment-content
!plugins/talk-plugin-deep-reply-count
!plugins/talk-plugin-downvote
!plugins/talk-plugin-facebook-auth
!plugins/talk-plugin-featured-comments
!plugins/talk-plugin-flag-details
@@ -52,17 +54,19 @@ plugins/*
!plugins/talk-plugin-remember-sort
!plugins/talk-plugin-respect
!plugins/talk-plugin-slack-notifications
!plugins/talk-plugin-sort-most-downvoted
!plugins/talk-plugin-sort-most-liked
!plugins/talk-plugin-sort-most-loved
!plugins/talk-plugin-sort-most-replied
!plugins/talk-plugin-sort-most-respected
!plugins/talk-plugin-sort-most-upvoted
!plugins/talk-plugin-sort-newest
!plugins/talk-plugin-sort-oldest
!plugins/talk-plugin-subscriber
!plugins/talk-plugin-toxic-comments
!plugins/talk-plugin-upvote
!plugins/talk-plugin-viewing-options
!plugins/talk-plugin-rich-text
!plugins/talk-plugin-rich-text-pell
!plugins/talk-plugin-auth-checkbox
**/node_modules/*
+1 -1
View File
@@ -1,6 +1,6 @@
{
"exec": "npm-run-all --parallel generate-introspection start:development",
"ignore": ["test/*", "client/*", "dist/*", "plugins/*/client"],
"ignore": ["test/*", "client/*", "dist/*", "plugins/*/client", "docs/*"],
"ext": "js,json,graphql,yml",
"watch": [
".",
+7 -3
View File
@@ -16,15 +16,19 @@ From getting up and running, to advanced configuration, to how to scale Talk, ou
## Product Guide
Learn more about Talk, including a deep dive into features for commenters and moderators, and FAQs in our [Talk Product Guide](https:/docs.coralproject.net/talk/how-talk-works).
Learn more about Talk, including a deep dive into features for commenters and moderators, and FAQs in our [Talk Product Guide](https://docs.coralproject.net/talk/how-talk-works).
## Relevant Links
## Pre-Launch Guide
Youve installed Talk on your server, and youre preparing to launch it on your site. The real community work starts now, before you go live. You have a unique opportunity pre-launch to set your community up for success. Read our [Talk Community Guide](https://blog.coralproject.net/youve-installed-talk-now-what/).
## More Resources
- [Talk Product Roadmap](https://www.pivotaltracker.com/n/projects/1863625)
- [Our Blog](https://blog.coralproject.net/)
- [Community Forums](https://community.coralproject.net/)
- [Community Guides for Journalism](https://guides.coralproject.net/)
- [More About Us](https://coralproject.net/)
- [Talk Roadmap](https://www.pivotaltracker.com/n/projects/1863625)
## End-to-End Testing
@@ -1,109 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { matchLinks } from '../utils';
import memoize from 'lodash/memoize';
function escapeRegExp(string) {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string
}
// generate a regulare expression that catches the `phrases`.
function generateRegExp(phrases) {
const inner = phrases
.map(phrase =>
phrase
.split(/\s+/)
.map(word => escapeRegExp(word))
.join('[\\s"?!.]+')
)
.join('|');
const pattern = `(^|[^\\w])(${inner})(?=[^\\w]|$)`;
try {
return new RegExp(pattern, 'iu');
} catch (_err) {
// IE does not support unicode support, so we'll create one without.
return new RegExp(pattern, 'i');
}
}
// Generate a regular expression detecting `suspectWords` and `bannedWords` phrases.
function getPhrasesRegexp(suspectWords, bannedWords) {
return generateRegExp([...suspectWords, ...bannedWords]);
}
// Memoized version as arguments rarely change.
const getPhrasesRegexpMemoized = memoize(getPhrasesRegexp);
// markPhrases looks for `supsectWords` and `bannedWords` inside `body` and highlights them by returning
// an array of React Elements.
function markPhrases(body, suspectWords, bannedWords, keyPrefix) {
const regexp = getPhrasesRegexpMemoized(suspectWords, bannedWords);
const tokens = body.split(regexp);
return tokens.map(
(token, i) =>
i % 3 === 2 ? <mark key={`${keyPrefix}_${i}`}>{token}</mark> : token
);
}
// markLinks looks for links inside `body` and highlights them by returning
// an array of React Elements.
function markLinks(body) {
const matches = matchLinks(body);
const content = [];
let index = 0;
if (matches) {
matches.forEach((match, i) => {
content.push(body.substring(index, match.index));
content.push(<mark key={i}>{match.text}</mark>);
index = match.lastIndex;
});
}
content.push(body.substring(index));
return content;
}
const CommentFormatter = ({
body,
suspectWords,
bannedWords,
className = 'comment',
...rest
}) => {
// Breaking the body by line break
const textbreaks = body.split('\n');
return (
<span className={`${className}-text`} {...rest}>
{textbreaks.map((line, i) => {
const content = markLinks(line).map((element, index) => {
// Keep highlighted links.
if (typeof element !== 'string') {
return element;
}
// Highlight suspect and banned phrase inside this part of text.
return markPhrases(element, suspectWords, bannedWords, index);
});
return (
<span key={i} className={`${className}-line`}>
{content}
{i !== textbreaks.length - 1 && (
<br className={`${className}-linebreak`} />
)}
</span>
);
})}
</span>
);
};
CommentFormatter.propTypes = {
className: PropTypes.string,
bannedWords: PropTypes.array,
suspectWords: PropTypes.array,
body: PropTypes.string,
};
export default CommentFormatter;
@@ -0,0 +1,8 @@
.container {
max-width: 1280px;
margin: 0 auto;
}
.copy {
padding: 20px 0;
}
@@ -0,0 +1,13 @@
import React from 'react';
import styles from './Forbidden.css';
const Forbidden = () => (
<div className={styles.container}>
<p className={styles.copy}>
This page is for team use only. Please contact an administrator if you
want to join this team.
</p>
</div>
);
export default Forbidden;
@@ -1,5 +1,5 @@
import React from 'react';
import { matchLinks } from '../utils';
import matchLinks from 'coral-framework/utils/matchLinks';
export default ({ text, children }) => {
const hasLinks = !!matchLinks(text);
+3 -1
View File
@@ -1,4 +1,6 @@
.layout {
margin: 0 auto;
background-color: #FAFAFA;
}
height: inherit;
min-height: calc(100vh - 58px);
}
@@ -5,7 +5,7 @@ import { Link } from 'react-router';
import { Icon } from 'coral-ui';
import CommentDetails from './CommentDetails';
import styles from './UserDetailComment.css';
import CommentFormatter from 'coral-admin/src/components/CommentFormatter';
import AdminCommentContent from 'coral-framework/components/AdminCommentContent';
import IfHasLink from 'coral-admin/src/components/IfHasLink';
import cn from 'classnames';
import CommentAnimatedEdit from './CommentAnimatedEdit';
@@ -93,7 +93,7 @@ class UserDetailComment extends React.Component {
'talk-admin-user-detail-comment'
)}
size={1}
defaultComponent={CommentFormatter}
defaultComponent={AdminCommentContent}
passthrough={slotPassthrough}
/>
<a
+2 -4
View File
@@ -11,6 +11,7 @@ import { logout } from 'coral-framework/actions/auth';
import { can } from 'coral-framework/services/perms';
import UserDetail from 'coral-admin/src/containers/UserDetail';
import PropTypes from 'prop-types';
import Forbidden from '../components/Forbidden';
class LayoutContainer extends React.Component {
render() {
@@ -47,10 +48,7 @@ class LayoutContainer extends React.Component {
} else {
return (
<Layout {...this.props} handleLogout={logout}>
<p>
This page is for team use only. Please contact an administrator if
you want to join this team.
</p>
<Forbidden />
</Layout>
);
}
@@ -8,7 +8,7 @@ import styles from './Comment.css';
import CommentLabels from 'coral-admin/src/components/CommentLabels';
import CommentAnimatedEdit from 'coral-admin/src/components/CommentAnimatedEdit';
import Slot from 'coral-framework/components/Slot';
import CommentFormatter from 'coral-admin/src/components/CommentFormatter';
import AdminCommentContent from 'coral-framework/components/AdminCommentContent';
import IfHasLink from 'coral-admin/src/components/IfHasLink';
import cn from 'classnames';
import ApproveButton from 'coral-admin/src/components/ApproveButton';
@@ -140,7 +140,7 @@ class Comment extends React.Component {
fill="adminCommentContent"
className={cn(styles.commentContent, 'talk-admin-comment')}
size={1}
defaultComponent={CommentFormatter}
defaultComponent={AdminCommentContent}
passthrough={{ ...slotPassthrough, ...formatterSettings }}
/>
<div className={styles.commentContentFooter}>
@@ -6,10 +6,6 @@
margin-top: 16px;
}
:global(html) {
height: inherit;
}
.list {
outline: none;
}
-9
View File
@@ -1,12 +1,3 @@
import LinkifyIt from 'linkify-it';
import tlds from 'tlds';
const linkify = new LinkifyIt();
linkify.tlds(tlds);
export function matchLinks(text) {
return linkify.match(text);
}
export const isPremod = mod => mod === 'PRE';
export const getModPath = (type = 'all', assetId) =>
@@ -71,16 +71,19 @@ export const setActiveTab = tab => dispatch => {
dispatch({ type: actions.SET_ACTIVE_TAB, tab });
};
// @Deprecated
export const addCommentBoxTag = tag => ({
type: actions.ADD_COMMENT_BOX_TAG,
tag,
});
// @Deprecated
export const removeCommentBoxTag = idx => ({
type: actions.REMOVE_COMMENT_BOX_TAG,
idx,
});
// @Deprecated
export const clearCommentBoxTags = () => ({
type: actions.CLEAR_COMMENT_BOX_TAGS,
});
@@ -35,12 +35,9 @@ class ExtendableTabPanelContainer extends React.Component {
createPluginTabFactory = (props = this.props) => el => {
return (
<ExtendableTab
tabId={el.type.talkPluginName}
key={el.type.talkPluginName}
>
<ExtendableTab tabId={el.key} key={el.key}>
{React.cloneElement(el, {
active: props.activeTab === el.type.talkPluginName,
active: props.activeTab === el.key,
})}
</ExtendableTab>
);
@@ -59,7 +56,7 @@ class ExtendableTabPanelContainer extends React.Component {
createPluginTabPane(el) {
return (
<TabPane tabId={el.type.talkPluginName} key={el.type.talkPluginName}>
<TabPane tabId={el.key} key={el.key}>
{el}
</TabPane>
);
@@ -8,7 +8,7 @@ const initialState = {
errors: {},
};
export default function config(state = initialState, action) {
export default function configure(state = initialState, action) {
switch (action.type) {
case actions.UPDATE_PENDING: {
let next = state;
@@ -1,39 +1,15 @@
import React from 'react';
import QuestionBox from '../../../components/QuestionBox';
import { Icon, Spinner } from 'coral-ui';
import DefaultQuestionBoxIcon from '../../../components/DefaultQuestionBoxIcon';
import cn from 'classnames';
import styles from './QuestionBoxBuilder.css';
import { Icon } from 'coral-ui';
import MarkdownEditor from 'coral-framework/components/MarkdownEditor';
const DefaultIcon = <DefaultQuestionBoxIcon className={styles.defaultIcon} />;
const icons = [{ default: DefaultIcon }, 'forum', 'build', 'format_quote'];
class QuestionBoxBuilder extends React.Component {
constructor() {
super();
this.state = {
loading: true,
};
}
componentWillMount() {
this.loadEditor();
}
async loadEditor() {
const {
default: MarkdownEditor,
} = await import(/* webpackChunkName: "markdownEditor" */
'coral-framework/components/MarkdownEditor');
return this.setState({
loading: false,
MarkdownEditor,
});
}
render() {
const {
questionBoxIcon,
@@ -41,11 +17,6 @@ class QuestionBoxBuilder extends React.Component {
onContentChange,
onIconChange,
} = this.props;
const { loading, MarkdownEditor } = this.state;
if (loading) {
return <Spinner />;
}
return (
<div className={styles.root}>
@@ -15,6 +15,7 @@
.main {
min-width: 70%;
max-width: 100%;
}
.sidebar {
@@ -16,7 +16,7 @@ import mapValues from 'lodash/mapValues';
import get from 'lodash/get';
import LoadMore from './LoadMore';
import { getEditableUntilDate } from './util';
import { getEditableUntilDate } from '../util';
import { findCommentWithId } from '../../../graphql/utils';
import CommentContent from 'coral-framework/components/CommentContent';
import Slot from 'coral-framework/components/Slot';
@@ -18,14 +18,8 @@ class CommentForm extends React.Component {
charCountEnable: PropTypes.bool.isRequired,
maxCharCount: PropTypes.number,
// DOM ID for form input that edits comment body
bodyInputId: PropTypes.string,
// screen reader label for input that edits comment body
bodyLabel: PropTypes.string,
// Placeholder for input that edits comment body
bodyPlaceholder: PropTypes.string,
// Unique identifier for this form
id: PropTypes.string,
// render at start of button container (useful for extra buttons)
buttonContainerStart: PropTypes.node,
@@ -37,15 +31,15 @@ class CommentForm extends React.Component {
submitButtonCStyle: PropTypes.string,
// return whether the submit button should be enabled for the provided
// comment ({ body }) (for reasons other than charCount)
// input (for reasons other than charCount)
submitEnabled: PropTypes.func,
// className to add to buttons
submitButtonClassName: PropTypes.string,
cancelButtonClassName: PropTypes.string,
body: PropTypes.string.isRequired,
onBodyChange: PropTypes.func.isRequired,
input: PropTypes.object.isRequired,
onInputChange: PropTypes.func.isRequired,
onSubmit: PropTypes.func.isRequired,
onCancel: PropTypes.func,
state: PropTypes.string,
@@ -53,13 +47,12 @@ class CommentForm extends React.Component {
registerHook: PropTypes.func,
unregisterHook: PropTypes.func,
isReply: PropTypes.bool,
isEdit: PropTypes.bool,
root: PropTypes.object.isRequired,
comment: PropTypes.object,
};
static get defaultProps() {
return {
bodyLabel: t('comment_box.comment'),
bodyPlaceholder: t('comment_box.comment'),
submitText: t('comment_box.post'),
submitButtonCStyle: 'darkGrey',
submitEnabled: () => true,
@@ -90,20 +83,20 @@ class CommentForm extends React.Component {
cancelButtonClassName,
submitButtonClassName,
charCountEnable,
body,
input,
loadingState,
comment,
root,
} = this.props;
const length = body.length;
const length = input.body.length;
const isRespectingMaxCount = length =>
charCountEnable && maxCharCount && length > maxCharCount;
const disableSubmitButton =
!length ||
body.trim().length === 0 ||
input.body.trim().length === 0 ||
isRespectingMaxCount(length) ||
!submitEnabled({ body }) ||
!submitEnabled(input) ||
loadingState === 'loading';
const disableCancelButton = loadingState === 'loading';
const disableTextArea = loadingState === 'loading';
@@ -113,17 +106,16 @@ class CommentForm extends React.Component {
<DraftArea
root={root}
comment={comment}
id={this.props.bodyInputId}
label={this.props.bodyLabel}
value={body}
placeholder={this.props.bodyPlaceholder}
onChange={this.props.onBodyChange}
id={this.props.id}
input={input}
onInputChange={this.props.onInputChange}
disabled={disableTextArea}
charCountEnable={this.props.charCountEnable}
maxCharCount={this.props.maxCharCount}
registerHook={this.props.registerHook}
unregisterHook={this.props.unregisterHook}
isReply={this.props.isReply}
isEdit={this.props.isEdit}
/>
<div className={cn(styles.buttonContainer, `${name}-button-container`)}>
{this.props.buttonContainerStart}
@@ -13,17 +13,17 @@ import styles from './DraftArea.css';
*/
export default class DraftArea extends React.Component {
renderCharCount() {
const { value, maxCharCount } = this.props;
const { input, maxCharCount } = this.props;
const className = cn(
styles.charCount,
'talk-plugin-commentbox-char-count',
{
[`${styles.charMax} talk-plugin-commentbox-char-max`]:
value.length > maxCharCount,
input.body.length > maxCharCount,
}
);
const remaining = maxCharCount - value.length;
const remaining = maxCharCount - input.body.length;
return (
<div className={className}>
@@ -32,18 +32,30 @@ export default class DraftArea extends React.Component {
);
}
getLabel() {
if (this.props.isEdit) {
return t('edit_comment.body_input_label');
}
return this.props.isReply ? t('comment_box.reply') : t('comment.comment');
}
getPlaceholder() {
if (this.props.isEdit) {
return '';
}
return this.getLabel();
}
render() {
const {
value,
placeholder,
input,
id,
disabled,
rows,
label,
charCountEnable,
maxCharCount,
onChange,
onInputChange,
isReply,
isEdit,
registerHook,
unregisterHook,
root,
@@ -51,31 +63,30 @@ export default class DraftArea extends React.Component {
} = this.props;
return (
<div>
<div id={id}>
<div
className={cn(styles.container, 'talk-plugin-commentbox-container')}
>
<label htmlFor={id} className="screen-reader-text" aria-hidden={true}>
{label}
</label>
<Slot
fill="draftArea"
defaultComponent={DraftAreaContent}
className={styles.content}
passthrough={{
id,
root,
comment,
registerHook,
unregisterHook,
value,
placeholder,
id,
onChange,
rows,
input,
onInputChange,
disabled,
isReply,
isEdit,
placeholder: this.getPlaceholder(),
label: this.getLabel(),
}}
/>
{/* Is this slot here legitimate? (kiwi) */}
<Slot fill="commentInputArea" />
</div>
{charCountEnable && maxCharCount > 0 && this.renderCharCount()}
@@ -84,23 +95,17 @@ export default class DraftArea extends React.Component {
}
}
DraftArea.defaultProps = {
rows: 3,
};
DraftArea.propTypes = {
charCountEnable: PropTypes.bool,
maxCharCount: PropTypes.number,
id: PropTypes.string,
value: PropTypes.string,
placeholder: PropTypes.string,
label: PropTypes.string,
onChange: PropTypes.func,
input: PropTypes.object,
onInputChange: PropTypes.func,
disabled: PropTypes.bool,
rows: PropTypes.number,
root: PropTypes.object.isRequired,
comment: PropTypes.object,
registerHook: PropTypes.func,
unregisterHook: PropTypes.func,
isReply: PropTypes.bool,
isEdit: PropTypes.bool,
};
@@ -3,36 +3,49 @@ import PropTypes from 'prop-types';
import cn from 'classnames';
import styles from './DraftAreaContent.css';
const DraftAreaContent = ({
value,
placeholder,
id,
onChange,
rows,
disabled,
}) => (
<textarea
className={cn(styles.content, 'talk-plugin-commentbox-textarea')}
value={value}
placeholder={placeholder}
id={id}
onChange={e => onChange(e.target.value)}
rows={rows}
disabled={disabled}
/>
);
DraftAreaContent.defaultProps = {
rows: 3,
};
class DraftAreaContent extends React.Component {
render() {
const {
input,
id,
onInputChange,
disabled,
label,
placeholder,
} = this.props;
const inputId = `${id}-textarea`;
return (
<div>
<label
htmlFor={inputId}
className="screen-reader-text"
aria-hidden={true}
>
{label}
</label>
<textarea
id={inputId}
className={cn(styles.content, 'talk-plugin-commentbox-textarea')}
value={input.body}
placeholder={placeholder}
onChange={e => onInputChange({ body: e.target.value })}
rows={3}
disabled={disabled}
/>
</div>
);
}
}
DraftAreaContent.propTypes = {
id: PropTypes.string,
value: PropTypes.string,
input: PropTypes.object,
placeholder: PropTypes.string,
onChange: PropTypes.func,
label: PropTypes.string,
onInputChange: PropTypes.func,
disabled: PropTypes.bool,
rows: PropTypes.number,
isEdit: PropTypes.bool,
isReply: PropTypes.bool,
};
export default DraftAreaContent;
@@ -1,12 +1,9 @@
import React from 'react';
import PropTypes from 'prop-types';
import { notifyForNewCommentStatus } from '../helpers';
import CommentForm from '../containers/CommentForm';
import styles from './Comment.css';
import { CountdownSeconds } from './CountdownSeconds';
import { getEditableUntilDate } from './util';
import { can } from 'coral-framework/services/perms';
import { Icon } from 'coral-ui';
import t from 'coral-framework/services/i18n';
@@ -15,187 +12,83 @@ import t from 'coral-framework/services/i18n';
* Renders a Comment's body in such a way that the end-user can edit it and save changes
*/
class EditableCommentContent extends React.Component {
static propTypes = {
// show notification to the user (e.g. for errors)
notify: PropTypes.func.isRequired,
root: PropTypes.object.isRequired,
// comment that is being edited
comment: PropTypes.shape({
id: PropTypes.string,
body: PropTypes.string,
editing: PropTypes.shape({
edited: PropTypes.bool,
// ISO8601
editableUntil: PropTypes.string,
}),
}).isRequired,
// logged in user
currentUser: PropTypes.shape({
id: PropTypes.string.isRequired,
}),
charCountEnable: PropTypes.bool,
maxCharCount: PropTypes.number,
// edit a comment, passed {{ body }}
editComment: PropTypes.func,
// called when editing should be stopped
stopEditing: PropTypes.func,
};
unmounted = false;
constructor(props) {
super(props);
this.editWindowExpiryTimeout = null;
this.state = {
body: props.comment.body,
loadingState: '',
// data: {@object} contains data that might be useful for plugins, metadata, etc
data: {},
};
}
componentDidMount() {
const editableUntil = getEditableUntilDate(this.props.comment);
const now = new Date();
const editWindowRemainingMs = editableUntil && editableUntil - now;
if (editWindowRemainingMs > 0) {
this.editWindowExpiryTimeout = setTimeout(() => {
this.forceUpdate();
}, editWindowRemainingMs);
}
}
componentWillUnmount() {
this.unmounted = true;
if (this.editWindowExpiryTimeout) {
this.editWindowExpiryTimeout = clearTimeout(this.editWindowExpiryTimeout);
}
}
handleBodyChange = (body, data) => {
this.setState(state => ({
body,
data: {
...state.data,
...data,
},
}));
};
handleSubmit = async () => {
if (!can(this.props.currentUser, 'INTERACT_WITH_COMMUNITY')) {
this.props.notify('error', t('error.NOT_AUTHORIZED'));
return;
}
this.setState({ loadingState: 'loading' });
const { editComment, stopEditing } = this.props;
if (typeof editComment !== 'function') {
return;
}
let input = {
body: this.state.body,
...this.state.data,
};
let response;
try {
response = await editComment(input);
if (!this.unmounted) {
this.setState({ loadingState: 'success' });
}
const status = response.data.editComment.comment.status;
notifyForNewCommentStatus(this.props.notify, status);
if (typeof stopEditing === 'function') {
stopEditing();
}
} catch (error) {
this.setState({ loadingState: 'error' });
}
};
getEditableUntil = (props = this.props) => {
return getEditableUntilDate(props.comment);
};
isEditWindowExpired = (props = this.props) => {
return this.getEditableUntil(props) - new Date() < 0;
};
isSubmitEnabled = comment => {
// should be disabled if user hasn't actually changed their
// original comment
renderButtonContainerStart() {
return (
comment.body !== this.props.comment.body && !this.isEditWindowExpired()
<div className={styles.buttonContainerLeft}>
<span className={styles.editWindowRemaining}>
{this.props.editWindowExpired ? (
<span>
{t('edit_comment.edit_window_expired')}
<span>
&nbsp;<a className={styles.link} onClick={this.props.onCancel}>
{t('edit_comment.edit_window_expired_close')}
</a>
</span>
</span>
) : (
<span>
<Icon name="timer" className={styles.timerIcon} />{' '}
{t('edit_comment.edit_window_timer_prefix')}
<CountdownSeconds
until={this.props.editableUntil}
classNameForMsRemaining={remainingMs =>
remainingMs <= 10 * 1000 ? styles.editWindowAlmostOver : ''
}
/>
</span>
)}
</span>
</div>
);
};
}
render() {
const id = `edit-draft_${this.props.comment.id}`;
return (
<div className={styles.editCommentForm}>
<CommentForm
isEdit
root={this.props.root}
comment={this.props.comment}
defaultValue={this.props.comment.body}
bodyInputId={id}
charCountEnable={this.props.charCountEnable}
maxCharCount={this.props.maxCharCount}
submitEnabled={this.isSubmitEnabled}
body={this.state.body}
onBodyChange={this.handleBodyChange}
onSubmit={this.handleSubmit}
submitEnabled={this.props.submitEnabled}
input={this.props.input}
onInputChange={this.props.onInputChange}
onSubmit={this.props.onSubmit}
onCancel={this.props.onCancel}
loadingState={this.props.loadingState}
registerHook={this.props.registerHook}
unregisterHook={this.props.unregisterHook}
buttonContainerStart={this.renderButtonContainerStart()}
submitButtonClassName={styles.button}
cancelButtonClassName={styles.button}
bodyLabel={t('edit_comment.body_input_label')}
bodyPlaceholder=""
submitText={<span>{t('edit_comment.save_button')}</span>}
submitButtonCStyle="green"
onCancel={this.props.stopEditing}
submitButtonClassName={styles.button}
cancelButtonClassName={styles.button}
loadingState={this.state.loadingState}
buttonContainerStart={
<div className={styles.buttonContainerLeft}>
<span className={styles.editWindowRemaining}>
{this.isEditWindowExpired() ? (
<span>
{t('edit_comment.edit_window_expired')}
{typeof this.props.stopEditing === 'function' ? (
<span>
&nbsp;<a
className={styles.link}
onClick={this.props.stopEditing}
>
{t('edit_comment.edit_window_expired_close')}
</a>
</span>
) : null}
</span>
) : (
<span>
<Icon name="timer" className={styles.timerIcon} />{' '}
{t('edit_comment.edit_window_timer_prefix')}
<CountdownSeconds
until={this.getEditableUntil()}
classNameForMsRemaining={remainingMs =>
remainingMs <= 10 * 1000
? styles.editWindowAlmostOver
: ''
}
/>
</span>
)}
</span>
</div>
}
id={id}
/>
</div>
);
}
}
EditableCommentContent.propTypes = {
charCountEnable: PropTypes.bool,
submitEnabled: PropTypes.func,
maxCharCount: PropTypes.number,
root: PropTypes.object.isRequired,
comment: PropTypes.object.isRequired,
input: PropTypes.object.isRequired,
registerHook: PropTypes.func.isRequired,
unregisterHook: PropTypes.func.isRequired,
onInputChange: PropTypes.func,
onSubmit: PropTypes.func,
onCancel: PropTypes.func,
loadingState: PropTypes.string,
editWindowExpired: PropTypes.bool,
editableUntil: PropTypes.object,
};
export default EditableCommentContent;
@@ -15,14 +15,13 @@ import QuestionBox from '../../../components/QuestionBox';
import { Tab, TabCount, TabPane } from 'coral-ui';
import cn from 'classnames';
import get from 'lodash/get';
import { reverseCommentParentTree } from '../../../graphql/utils';
import AllCommentsPane from './AllCommentsPane';
import ExtendableTabPanel from '../../../containers/ExtendableTabPanel';
import ChangedUsername from './ChangedUsername';
import CommentNotFound from '../containers/CommentNotFound';
import styles from './Stream.css';
import ChangedUsername from './ChangedUsername';
class Stream extends React.Component {
constructor(props) {
super(props);
@@ -238,7 +237,11 @@ class Stream extends React.Component {
keepCommentBox);
if (highlightedComment === null) {
return <StreamError>{t('stream.comment_not_found')}</StreamError>;
return (
<StreamError>
<CommentNotFound />
</StreamError>
);
}
const slotPassthrough = { root, asset };
@@ -16,7 +16,6 @@ import { nest } from '../../../graphql/utils';
const slots = [
'streamQuestionArea',
'commentInputArea',
'commentInputDetailArea',
'commentInfoBar',
'commentActions',
@@ -7,10 +7,24 @@ import Slot from 'coral-framework/components/Slot';
import { connect } from 'react-redux';
import CommentForm from '../containers/CommentForm';
import { notifyForNewCommentStatus } from '../helpers';
import withHooks from '../hocs/withHooks';
import { compose } from 'recompose';
import once from 'lodash/once';
// TODO: (kiwi) Need to adapt CSS classes post refactor to match the rest.
export const name = 'talk-plugin-commentbox';
// @Deprecated
const showOldTagsWarningOnce = once(() => {
if (process.env.NODE_ENV !== 'production') {
console.warn(
'Using `addTags` and `removeTags` is deprecated. Please switch to `onInputChange` and `input` instead'
);
}
});
const initialInput = { body: '', tags: [] };
/**
* Container for posting a new Comment
*/
@@ -19,14 +33,8 @@ class CommentBox extends React.Component {
super(props);
this.state = {
body: '',
loadingState: '',
// data: {@object} contains data that might be useful for plugins
data: {},
hooks: {
preSubmit: [],
postSubmit: [],
},
input: initialInput,
};
}
@@ -56,29 +64,38 @@ class CommentBox extends React.Component {
return;
}
// @Deprecated
const deprecatedTags = this.props.tags || [];
if (deprecatedTags.length) {
showOldTagsWarningOnce();
}
const tags = this.state.input.tags || [];
let input = {
asset_id: assetId,
parent_id: parentId,
body: this.state.body,
tags: this.props.tags,
...this.state.data,
...this.state.input,
tags: [...deprecatedTags, ...tags],
};
// Execute preSubmit Hooks
this.state.hooks.preSubmit.forEach(hook =>
hook(input, this.handleBodyChange)
);
this.props.forEachHook('preSubmit', hook => {
const result = hook(input);
if (result) {
input = result;
}
});
this.setState({ loadingState: 'loading' });
postComment(input, 'comments')
.then(({ data }) => {
this.setState({ loadingState: 'success', body: '' });
this.setState({ loadingState: 'success', input: initialInput });
const postedComment = data.createComment.comment;
const actions = data.createComment.actions;
// Execute postSubmit Hooks
this.state.hooks.postSubmit.forEach(hook =>
hook(data, this.handleBodyChange)
this.props.forEachHook('postSubmit', hook =>
hook(data, this.handleInputChange)
);
notifyForNewCommentStatus(notify, postedComment.status, actions);
@@ -92,62 +109,32 @@ class CommentBox extends React.Component {
});
};
handleBodyChange = (body, data) => {
handleInputChange = input => {
this.setState(state => ({
body,
data: {
...state.data,
...data,
input: {
...state.input,
...input,
},
}));
};
registerHook = (hookType = '', hook = () => {}) => {
if (typeof hook !== 'function') {
return console.warn(
`Hooks must be functions. Please check your ${hookType} hooks`
);
} else if (typeof hookType === 'string') {
this.setState(state => ({
hooks: {
...state.hooks,
[hookType]: [...state.hooks[hookType], hook],
},
}));
return {
hookType,
hook,
};
} else {
return console.warn(
'hookTypes must be a string. Please check your hooks'
);
}
};
unregisterHook = hookData => {
const { hookType, hook } = hookData;
this.setState(state => {
let newHooks = state.hooks[newHooks];
const idx = state.hooks[hookType].indexOf(hook);
if (idx !== -1) {
newHooks = [
...state.hooks[hookType].slice(0, idx),
...state.hooks[hookType].slice(idx + 1),
];
}
return {
hooks: {
...state.hooks,
[hookType]: newHooks,
},
};
});
};
renderButtonContainerStart() {
const { root, isReply, registerHook, unregisterHook } = this.props;
return (
<Slot
fill="commentInputDetailArea"
passthrough={{
root,
registerHook: registerHook,
unregisterHook: unregisterHook,
isReply,
input: this.state.input,
onInputChange: this.handleInputChange,
}}
inline
/>
);
}
render() {
const {
@@ -157,6 +144,8 @@ class CommentBox extends React.Component {
parentId,
comment,
root,
registerHook,
unregisterHook,
} = this.props;
let { onCancel } = this.props;
@@ -178,27 +167,15 @@ class CommentBox extends React.Component {
root={root}
comment={comment}
defaultValue={this.props.defaultValue}
bodyLabel={isReply ? t('comment_box.reply') : t('comment.comment')}
maxCharCount={maxCharCount}
charCountEnable={this.props.charCountEnable}
bodyPlaceholder={t('comment.comment')}
bodyInputId={id}
body={this.state.body}
registerHook={this.registerHook}
unregisterHook={this.unregisterHook}
id={id}
input={this.state.input}
registerHook={registerHook}
unregisterHook={unregisterHook}
isReply={isReply}
buttonContainerStart={
<Slot
fill="commentInputDetailArea"
passthrough={{
registerHook: this.registerHook,
unregisterHook: this.unregisterHook,
isReply,
}}
inline
/>
}
onBodyChange={this.handleBodyChange}
buttonContainerStart={this.renderButtonContainerStart()}
onInputChange={this.handleInputChange}
loadingState={this.state.loadingState}
onCancel={onCancel}
onSubmit={this.handleSubmit}
@@ -225,6 +202,9 @@ CommentBox.propTypes = {
tags: PropTypes.array,
root: PropTypes.object.isRequired,
comment: PropTypes.object,
registerHook: PropTypes.func.isRequired,
unregisterHook: PropTypes.func.isRequired,
forEachHook: PropTypes.func.isRequired,
};
CommentBox.fragments = CommentForm.fragments;
@@ -233,4 +213,9 @@ const mapStateToProps = state => ({
tags: state.stream.commentBoxTags,
});
export default connect(mapStateToProps, null)(CommentBox);
const enhance = compose(
withHooks(['preSubmit', 'postSubmit']),
connect(mapStateToProps, null)
);
export default enhance(CommentBox);
@@ -0,0 +1,36 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Button } from 'coral-ui';
import { setActiveTab } from '../../../actions/embed';
import { bindActionCreators } from 'redux';
import { connect } from 'react-redux';
import t from 'coral-framework/services/i18n';
class CommentNotFound extends React.Component {
showAllTab = () => {
this.props.setActiveTab('all');
};
render() {
return (
<div>
<p>{t('stream.comment_not_found')}</p>
<Button onClick={this.showAllTab}>Show all comments</Button>
</div>
);
}
}
CommentNotFound.propTypes = {
setActiveTab: PropTypes.func,
};
const mapDispatchToProps = dispatch =>
bindActionCreators(
{
setActiveTab,
},
dispatch
);
export default connect(null, mapDispatchToProps)(CommentNotFound);
@@ -17,9 +17,17 @@ class DraftAreaContainer extends React.Component {
}
async initValue() {
const value = await this.context.pymSessionStorage.getItem(this.getPath());
if (value && this.props.onChange) {
this.props.onChange(value);
const input = await this.context.pymSessionStorage.getItem(this.getPath());
if (input && this.props.onInputChange) {
let parsed = '';
// Older version saved a normal string, catch those and ignore them.
try {
parsed = JSON.parse(input);
} catch (_e) {}
if (typeof parsed === 'object') {
this.props.onInputChange(parsed);
}
}
}
@@ -27,14 +35,13 @@ class DraftAreaContainer extends React.Component {
return `${STORAGE_PATH}_${this.props.id}`;
};
onChange = (body, data) => {
this.props.onChange && this.props.onChange(body, data);
};
componentWillReceiveProps(nextProps) {
if (this.props.value !== nextProps.value) {
if (nextProps.value) {
this.context.pymSessionStorage.setItem(this.getPath(), nextProps.value);
if (this.props.input !== nextProps.input) {
if (nextProps.input) {
this.context.pymSessionStorage.setItem(
this.getPath(),
JSON.stringify(nextProps.input)
);
} else {
this.context.pymSessionStorage.removeItem(this.getPath());
}
@@ -46,18 +53,16 @@ class DraftAreaContainer extends React.Component {
<DraftArea
root={this.props.root}
comment={this.props.comment}
value={this.props.value}
placeholder={this.props.placeholder}
input={this.props.input}
id={this.props.id}
onChange={this.onChange}
rows={this.props.rows}
onInputChange={this.props.onInputChange}
disabled={this.props.disabled}
charCountEnable={this.props.charCountEnable}
maxCharCount={this.props.maxCharCount}
label={this.props.label}
registerHook={this.props.registerHook}
unregisterHook={this.props.unregisterHook}
isReply={this.props.isReply}
isEdit={this.props.isEdit}
/>
);
}
@@ -73,20 +78,18 @@ DraftAreaContainer.propTypes = {
charCountEnable: PropTypes.bool,
maxCharCount: PropTypes.number,
id: PropTypes.string.isRequired,
value: PropTypes.string.isRequired,
placeholder: PropTypes.string,
onChange: PropTypes.func.isRequired,
input: PropTypes.object.isRequired,
onInputChange: PropTypes.func.isRequired,
disabled: PropTypes.bool,
rows: PropTypes.number,
label: PropTypes.string.isRequired,
registerHook: PropTypes.func,
unregisterHook: PropTypes.func,
isReply: PropTypes.bool,
isEdit: PropTypes.bool,
root: PropTypes.object.isRequired,
comment: PropTypes.object,
};
const slots = ['draftArea'];
const slots = ['draftArea', 'commentInputArea'];
export default withFragments({
root: gql`
@@ -1,11 +1,150 @@
import React from 'react';
import PropTypes from 'prop-types';
import { notifyForNewCommentStatus } from '../helpers';
import { getEditableUntilDate } from '../util';
import { can } from 'coral-framework/services/perms';
import t from 'coral-framework/services/i18n';
import EditableCommentContent from '../components/EditableCommentContent';
import CommentForm from './CommentForm';
import withHooks from '../hocs/withHooks';
import { compose } from 'recompose';
const EditableCommentContentContainer = props => (
<EditableCommentContent {...props} />
);
/**
* Renders a Comment's body in such a way that the end-user can edit it and save changes
*/
class EditableCommentContentContainer extends React.Component {
unmounted = false;
editWindowExpiryTimeout = null;
state = {
loadingState: '',
submitEnabled: false,
input: {
body: this.props.comment.body,
},
};
componentDidMount() {
const editableUntil = getEditableUntilDate(this.props.comment);
const now = new Date();
const editWindowRemainingMs = editableUntil && editableUntil - now;
if (editWindowRemainingMs > 0) {
this.editWindowExpiryTimeout = setTimeout(() => {
this.forceUpdate();
}, editWindowRemainingMs);
}
}
componentWillUnmount() {
this.unmounted = true;
if (this.editWindowExpiryTimeout) {
this.editWindowExpiryTimeout = clearTimeout(this.editWindowExpiryTimeout);
}
}
handleInputChange = input => {
this.setState(state => ({
submitEnabled: true,
input: {
...state.input,
...input,
},
}));
};
handleSubmit = async () => {
if (!can(this.props.currentUser, 'INTERACT_WITH_COMMUNITY')) {
this.props.notify('error', t('error.NOT_AUTHORIZED'));
return;
}
this.setState({ loadingState: 'loading' });
const { editComment, stopEditing } = this.props;
if (typeof editComment !== 'function') {
return;
}
let input = this.state.input;
// Execute preSubmit Hooks
this.props.forEachHook('preSubmit', hook => {
const result = hook(input);
if (result) {
input = result;
}
});
let response;
try {
response = await editComment(input);
// Execute postSubmit Hooks
this.props.forEachHook('postSubmit', hook =>
hook(response, this.handleInputChange)
);
if (!this.unmounted) {
this.setState({ loadingState: 'success' });
}
const status = response.data.editComment.comment.status;
notifyForNewCommentStatus(this.props.notify, status);
if (typeof stopEditing === 'function') {
stopEditing();
}
} catch (error) {
this.setState({ loadingState: 'error' });
}
};
getEditableUntil = (props = this.props) => {
return getEditableUntilDate(props.comment);
};
isEditWindowExpired = (props = this.props) => {
return this.getEditableUntil(props) - new Date() < 0;
};
isSubmitEnabled = () => this.state.submitEnabled;
render() {
return (
<EditableCommentContent
charCountEnable={this.props.charCountEnable}
submitEnabled={this.isSubmitEnabled}
maxCharCount={this.props.maxCharCount}
root={this.props.root}
comment={this.props.comment}
input={this.state.input}
onInputChange={this.handleInputChange}
onSubmit={this.handleSubmit}
onCancel={this.props.stopEditing}
loadingState={this.state.loadingState}
editWindowExpired={this.isEditWindowExpired()}
editableUntil={this.getEditableUntil()}
registerHook={this.props.registerHook}
unregisterHook={this.props.unregisterHook}
/>
);
}
}
EditableCommentContentContainer.propTypes = {
notify: PropTypes.func.isRequired,
root: PropTypes.object.isRequired,
comment: PropTypes.object.isRequired,
currentUser: PropTypes.object,
charCountEnable: PropTypes.bool,
maxCharCount: PropTypes.number,
editComment: PropTypes.func,
stopEditing: PropTypes.func,
registerHook: PropTypes.func.isRequired,
unregisterHook: PropTypes.func.isRequired,
forEachHook: PropTypes.func.isRequired,
};
EditableCommentContentContainer.fragments = CommentForm.fragments;
export default EditableCommentContentContainer;
const enhance = compose(withHooks(['preSubmit', 'postSubmit']));
export default enhance(EditableCommentContentContainer);
@@ -367,6 +367,7 @@ const LOAD_MORE_QUERY = gql`
`;
const slots = [
'commentInputDetailArea',
'streamTabs',
'streamTabsPrepend',
'streamTabPanes',
@@ -465,7 +466,6 @@ const mapStateToProps = state => ({
activeStreamTab: state.stream.activeTab,
previousStreamTab: state.stream.previousTab,
commentClassNames: state.stream.commentClassNames,
pluginConfig: state.config.plugin_config,
sortOrder: state.stream.sortOrder,
sortBy: state.stream.sortBy,
});
@@ -0,0 +1,57 @@
import React from 'react';
import hoistStatics from 'recompose/hoistStatics';
/**
* WithHooks provides a property `hooks` to the wrapped component.
*/
export default hooks =>
hoistStatics(WrappedComponent => {
class WithHooks extends React.Component {
hooks = hooks.reduce((map, key) => {
map[key] = [];
return map;
}, {});
registerHook = (hookType = '', hook) => {
if (typeof hook !== 'function') {
return console.warn(
`Hooks must be functions. Please check your ${hookType} hooks`
);
}
if (!hooks.includes(hookType)) {
throw new Error(`Unknown hookType ${hookType}`);
}
this.hooks[hookType].push(hook);
return {
hookType,
hook,
};
};
unregisterHook = hookData => {
const { hookType, hook } = hookData;
const idx = this.hooks[hookType].indexOf(hook);
if (idx !== -1) {
this.hooks[hookType].splice(idx, 1);
}
};
forEachHook = (hookType, callback) => {
this.hooks[hookType].forEach(callback);
};
render() {
return (
<WrappedComponent
{...this.props}
registerHook={this.registerHook}
unregisterHook={this.unregisterHook}
forEachHook={this.forEachHook}
/>
);
}
}
return WithHooks;
});
+8
View File
@@ -161,6 +161,14 @@ export default class Stream {
);
}
enablePluginsDebug() {
this.pym.sendMessage('enablePluginsDebug');
}
disablePluginsDebug() {
this.pym.sendMessage('disablePluginsDebug');
}
login(token) {
this.pym.sendMessage('login', token);
}
+12
View File
@@ -7,6 +7,10 @@ export default class StreamInterface {
return this._stream.emitter.on(eventName, callback);
}
off(eventName, callback) {
return this._stream.emitter.off(eventName, callback);
}
login(token) {
return this._stream.login(token);
}
@@ -18,4 +22,12 @@ export default class StreamInterface {
remove() {
return this._stream.remove();
}
enablePluginsDebug() {
return this._stream.enablePluginsDebug();
}
disablePluginsDebug() {
return this._stream.disablePluginsDebug();
}
}
+13 -1
View File
@@ -1,6 +1,18 @@
import { MERGE_CONFIG } from '../constants/config';
import {
MERGE_CONFIG,
ENABLE_PLUGINS_DEBUG,
DISABLE_PLUGINS_DEBUG,
} from '../constants/config';
export const mergeConfig = config => ({
type: MERGE_CONFIG,
config,
});
export const enablePluginsDebug = () => ({
type: ENABLE_PLUGINS_DEBUG,
});
export const disablePluginsDebug = () => ({
type: DISABLE_PLUGINS_DEBUG,
});
@@ -0,0 +1,16 @@
.content {
a {
color: #063b9a;
text-decoration: underline;
font-weight: 300;
background-color: #f4ff81;
}
mark {
background-color: #f4ff81;
}
b, strong {
font-weight: 600;
}
}
@@ -0,0 +1,217 @@
import React from 'react';
import PropTypes from 'prop-types';
import matchLinks from '../utils/matchLinks';
import memoize from 'lodash/memoize';
import cn from 'classnames';
import styles from './AdminCommentContent.css';
function escapeHTML(unsafe) {
return unsafe
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
function escapeRegExp(string) {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string
}
// generate a regulare expression that catches the `phrases`.
function generateRegExp(phrases) {
const inner = phrases
.map(phrase =>
phrase
.split(/\s+/)
.map(word => escapeRegExp(word))
.join('[\\s"?!.]+')
)
.join('|');
const pattern = `(^|[^\\w])(${inner})(?=[^\\w]|$)`;
try {
return new RegExp(pattern, 'iu');
} catch (_err) {
// IE does not support unicode support, so we'll create one without.
return new RegExp(pattern, 'i');
}
}
// Generate a regular expression detecting `suspectWords` and `bannedWords` phrases.
function getPhrasesRegexp(suspectWords, bannedWords) {
return generateRegExp([...suspectWords, ...bannedWords]);
}
// Memoized version as arguments rarely change.
const getPhrasesRegexpMemoized = memoize(getPhrasesRegexp);
function nl2br(body, keyPrefix) {
const tokens = body.split('\n').reduce((tokens, t, i) => {
if (i !== 0) {
tokens.push(<br key={`${keyPrefix}_${i}`} />);
}
tokens.push(t);
return tokens;
}, []);
return tokens;
}
// markPhrases looks for `supsectWords` and `bannedWords` inside `body` and highlights them by returning
// an array of React Elements.
function markPhrases(body, suspectWords, bannedWords, keyPrefix) {
const regexp = getPhrasesRegexpMemoized(suspectWords, bannedWords);
const tokens = body.split(regexp);
return tokens.map(
(token, i) =>
i % 3 === 2 ? <mark key={`${keyPrefix}_${i}`}>{token}</mark> : token
);
}
// markLinks looks for links inside `body` and highlights them by returning
// an array of React Elements.
function markLinks(body, keyPrefix) {
const matches = matchLinks(body);
const content = [];
let index = 0;
if (matches) {
matches.forEach((match, i) => {
content.push(body.substring(index, match.index));
content.push(
<a key={`${keyPrefix}_${i}`} href={match.url} target="_blank">
{match.text}
</a>
);
index = match.lastIndex;
});
}
content.push(body.substring(index));
return content;
}
// markPhrasesHTML looks for `supsectWords` and `bannedWords` inside `text` and highlights them by returning
// a HTML string.
function markPhrasesHTML(text, suspectWords, bannedWords) {
const regexp = getPhrasesRegexpMemoized(suspectWords, bannedWords);
const tokens = text.split(regexp);
if (tokens.length === 1) {
return text;
}
return tokens
.map(
(token, i) =>
i % 3 === 2 ? `<mark>${escapeHTML(token)}</mark>` : escapeHTML(token)
)
.join('');
}
// markHTMLNode manipulates the node by looking for #text nodes and adding markers
// for `supsectWords` and `bannedWords`.
function markHTMLNode(parentNode, suspectWords, bannedWords) {
parentNode.childNodes.forEach(node => {
if (node.nodeName === '#text') {
const newContent = markPhrasesHTML(
node.textContent,
suspectWords,
bannedWords
);
if (newContent !== node.textContent) {
const newNode = document.createElement('span');
newNode.innerHTML = newContent;
parentNode.replaceChild(newNode, node);
}
} else {
markHTMLNode(node, suspectWords, bannedWords);
}
});
}
// renderText performs all the marking of a text body and returns an array of React Elements.
function renderText(body, suspectWords, bannedWords) {
return nl2br(body).map((element, index) => {
// Skip br tags.
if (typeof element !== 'string') {
return element;
}
return markLinks(element, index).map((element, index) => {
// Keep highlighted links.
if (typeof element !== 'string') {
return element;
}
// Highlight suspect and banned phrase inside this part of text.
return markPhrases(element, suspectWords, bannedWords, index);
});
});
}
const commonPropTypes = {
className: PropTypes.string,
bannedWords: PropTypes.array.isRequired,
suspectWords: PropTypes.array.isRequired,
body: PropTypes.string.isRequired,
};
const AdminCommentContentText = ({
body,
className,
suspectWords,
bannedWords,
}) => {
return (
<div className={cn(className, styles.content)}>
{renderText(body, suspectWords, bannedWords)}
</div>
);
};
AdminCommentContentText.propTypes = commonPropTypes;
const AdminCommentContentHTML = ({
body,
className,
suspectWords,
bannedWords,
}) => {
// We create a Shadow DOM Tree with the HTML body content and
// use it as a parser.
const node = document.createElement('div');
node.innerHTML = body;
// Then we traverse it recursively and manipulate it to highlight suspect words
// and banned words.
markHTMLNode(node, suspectWords, bannedWords);
// Finally we render the content of the Shadow DOM Tree
return (
<div
className={cn(className, styles.content)}
dangerouslySetInnerHTML={{ __html: node.innerHTML }}
/>
);
};
AdminCommentContentHTML.propTypes = commonPropTypes;
const AdminCommentContent = ({
className,
body,
suspectWords,
bannedWords,
html,
}) => {
const Component = html ? AdminCommentContentHTML : AdminCommentContentText;
return (
<Component
className={className}
body={body}
suspectWords={suspectWords}
bannedWords={bannedWords}
/>
);
};
AdminCommentContent.propTypes = {
...commonPropTypes,
html: PropTypes.bool,
};
export default AdminCommentContent;
@@ -1,154 +1,11 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import SimpleMDE from 'simplemde';
import cn from 'classnames';
import noop from 'lodash/noop';
import styles from './MarkdownEditor.css';
import { Spinner } from 'coral-ui';
import Loadable from 'react-loadable';
const config = {
status: false,
const MarkdownEditor = Loadable({
loader: () =>
import(/* webpackChunkName: "markdownEditor" */
'./loadable/MarkdownEditor'),
loading: Spinner,
});
// Do not download fontAwesome icons as we replace them with
// material icons.
autoDownloadFontAwesome: false,
// Disable built-in spell checker as it is very rudimentary.
spellChecker: false,
toolbar: [
{
name: 'bold',
action: SimpleMDE.toggleBold,
className: styles.iconBold,
title: 'Bold',
},
{
name: 'italic',
action: SimpleMDE.toggleItalic,
className: styles.iconItalic,
title: 'Italic',
},
{
name: 'title',
action: SimpleMDE.toggleHeadingSmaller,
className: styles.iconTitle,
title: 'Title, Subtitle, Heading',
},
'|',
{
name: 'quote',
action: SimpleMDE.toggleBlockquote,
className: styles.iconQuote,
title: 'Quote',
},
{
name: 'unordered-list',
action: SimpleMDE.toggleUnorderedList,
className: styles.iconUnorderedList,
title: 'Generic List',
},
{
name: 'ordered-list',
action: SimpleMDE.toggleOrderedList,
className: styles.iconOrderedList,
title: 'Numbered List',
},
'|',
{
name: 'link',
action: SimpleMDE.drawLink,
className: styles.iconLink,
title: 'Create Link',
},
{
name: 'image',
action: SimpleMDE.drawImage,
className: styles.iconImage,
title: 'Insert Image',
},
'|',
{
name: 'preview',
action: SimpleMDE.togglePreview,
className: cn(styles.iconPreview, 'no-disable'),
title: 'Toggle Preview',
},
{
name: 'side-by-side',
action: SimpleMDE.toggleSideBySide,
className: cn(styles.iconSideBySide, 'no-disable'),
title: 'Toggle Side by Side',
},
{
name: 'fullscreen',
action: SimpleMDE.toggleFullScreen,
className: cn(styles.iconFullscreen, 'no-disable'),
title: 'Toggle Fullscreen',
},
'|',
{
name: 'guide',
action: 'https://simplemde.com/markdown-guide',
className: styles.iconGuide,
title: 'Markdown Guide',
},
],
};
export default class MarkdownEditor extends Component {
textarea = null;
editor = null;
onRef = ref => (this.textarea = ref);
componentDidMount() {
this.editor = new SimpleMDE({
...config,
element: this.textarea,
});
// Don't trap the key, to stay accessible.
this.editor.codemirror.options.extraKeys['Tab'] = false;
this.editor.codemirror.options.extraKeys['Shift-Tab'] = false;
this.editor.codemirror.on('change', this.onChange);
}
componentWillReceiveProps(nextProps) {
if (
this.props.value !== nextProps.value &&
nextProps.value !== this.editor.value()
) {
this.editor.value(nextProps.value);
}
}
componentDidUpdate() {
// Workaround empty render issue.
// https://github.com/NextStepWebs/simplemde-markdown-editor/issues/313
this.editor.codemirror.refresh();
}
componentWillUnmount() {
this.editor.toTextArea();
}
onChange = () => {
if (this.props.onChange) {
this.props.onChange(this.editor.value());
}
};
render() {
return (
<div className={styles.wrapper}>
<textarea ref={this.onRef} {...this.props} onChange={noop} />
</div>
);
}
}
MarkdownEditor.propTypes = {
onChange: PropTypes.func,
value: PropTypes.string,
};
export default MarkdownEditor;
+32 -1
View File
@@ -3,5 +3,36 @@
}
.debug {
background-color: coral;
background-color: #e2e2e2;
border-style: dotted solid;
border-width: 2px;
border: dotted 2px coral;
padding: 2px;
margin: 1px;
position: relative;
}
.debug::before {
content: attr(data-slot-name);
display: inline-block;
position: absolute;
background: #000;
color: #FFF;
padding: 5px;
border-radius: 5px;
opacity: 0;
transition: 0.3s;
overflow: hidden;
pointer-events: none;
z-index: 999!important;
white-space: pre-wrap;
min-height: 16px;
top: 50%;
left: 0;
}
.debug:hover::before {
opacity: 1;
top: 100%;
}
@@ -30,6 +30,7 @@ class Slot extends React.Component {
className,
`talk-slot-${kebabCase(fill)}`
)}
data-slot-name={fill}
>
{children}
</Component>
@@ -0,0 +1,154 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import SimpleMDE from 'simplemde';
import cn from 'classnames';
import noop from 'lodash/noop';
import styles from './MarkdownEditor.css';
const config = {
status: false,
// Do not download fontAwesome icons as we replace them with
// material icons.
autoDownloadFontAwesome: false,
// Disable built-in spell checker as it is very rudimentary.
spellChecker: false,
toolbar: [
{
name: 'bold',
action: SimpleMDE.toggleBold,
className: styles.iconBold,
title: 'Bold',
},
{
name: 'italic',
action: SimpleMDE.toggleItalic,
className: styles.iconItalic,
title: 'Italic',
},
{
name: 'title',
action: SimpleMDE.toggleHeadingSmaller,
className: styles.iconTitle,
title: 'Title, Subtitle, Heading',
},
'|',
{
name: 'quote',
action: SimpleMDE.toggleBlockquote,
className: styles.iconQuote,
title: 'Quote',
},
{
name: 'unordered-list',
action: SimpleMDE.toggleUnorderedList,
className: styles.iconUnorderedList,
title: 'Generic List',
},
{
name: 'ordered-list',
action: SimpleMDE.toggleOrderedList,
className: styles.iconOrderedList,
title: 'Numbered List',
},
'|',
{
name: 'link',
action: SimpleMDE.drawLink,
className: styles.iconLink,
title: 'Create Link',
},
{
name: 'image',
action: SimpleMDE.drawImage,
className: styles.iconImage,
title: 'Insert Image',
},
'|',
{
name: 'preview',
action: SimpleMDE.togglePreview,
className: cn(styles.iconPreview, 'no-disable'),
title: 'Toggle Preview',
},
{
name: 'side-by-side',
action: SimpleMDE.toggleSideBySide,
className: cn(styles.iconSideBySide, 'no-disable'),
title: 'Toggle Side by Side',
},
{
name: 'fullscreen',
action: SimpleMDE.toggleFullScreen,
className: cn(styles.iconFullscreen, 'no-disable'),
title: 'Toggle Fullscreen',
},
'|',
{
name: 'guide',
action: 'https://simplemde.com/markdown-guide',
className: styles.iconGuide,
title: 'Markdown Guide',
},
],
};
export default class MarkdownEditor extends Component {
textarea = null;
editor = null;
onRef = ref => (this.textarea = ref);
componentDidMount() {
this.editor = new SimpleMDE({
...config,
element: this.textarea,
});
// Don't trap the key, to stay accessible.
this.editor.codemirror.options.extraKeys['Tab'] = false;
this.editor.codemirror.options.extraKeys['Shift-Tab'] = false;
this.editor.codemirror.on('change', this.onChange);
}
componentWillReceiveProps(nextProps) {
if (
this.props.value !== nextProps.value &&
nextProps.value !== this.editor.value()
) {
this.editor.value(nextProps.value);
}
}
componentDidUpdate() {
// Workaround empty render issue.
// https://github.com/NextStepWebs/simplemde-markdown-editor/issues/313
this.editor.codemirror.refresh();
}
componentWillUnmount() {
this.editor.toTextArea();
}
onChange = () => {
if (this.props.onChange) {
this.props.onChange(this.editor.value());
}
};
render() {
return (
<div className={styles.wrapper}>
<textarea ref={this.onRef} {...this.props} onChange={noop} />
</div>
);
}
}
MarkdownEditor.propTypes = {
onChange: PropTypes.func,
value: PropTypes.string,
};
@@ -1,3 +1,5 @@
const prefix = `TALK_FRAMEWORK`;
export const MERGE_CONFIG = `${prefix}_MERGE_CONFIG`;
export const ENABLE_PLUGINS_DEBUG = `${prefix}_ENABLE_PLUGINS_DEBUG`;
export const DISABLE_PLUGINS_DEBUG = `${prefix}_DISABLE_PLUGINS_DEBUG`;
@@ -83,6 +83,13 @@ const createHOC = ({
}
if (changes.length === 1 && changes[0] === 'reduxState') {
// If config changed, we'll have to rerender everything.
// Should only happen during development as this is
// usually static.
if (this.props.reduxState.config !== next.reduxState.config) {
return true;
}
const prevChildrenKeys = this.getSlotElements(this.props).map(
child => child.key
);
+21 -1
View File
@@ -1,10 +1,30 @@
import { MERGE_CONFIG } from '../constants/config';
import {
MERGE_CONFIG,
ENABLE_PLUGINS_DEBUG,
DISABLE_PLUGINS_DEBUG,
} from '../constants/config';
import { LOGOUT } from '../constants/auth';
const initialState = {};
export default function config(state = initialState, action) {
switch (action.type) {
case ENABLE_PLUGINS_DEBUG:
return {
...state,
plugins_config: {
...state.plugins_config,
debug: true,
},
};
case DISABLE_PLUGINS_DEBUG:
return {
...state,
plugins_config: {
...state.plugins_config,
debug: false,
},
};
case LOGOUT:
return {
...state,
+26 -3
View File
@@ -25,7 +25,11 @@ import { createIntrospection } from 'coral-framework/services/introspection';
import introspectionData from 'coral-framework/graphql/introspection.json';
import coreReducers from '../reducers';
import { checkLogin as checkLoginAction } from '../actions/auth';
import { mergeConfig } from '../actions/config';
import {
mergeConfig,
enablePluginsDebug,
disablePluginsDebug,
} from '../actions/config';
import { setAuthToken, logout } from '../actions/auth';
/**
@@ -62,8 +66,19 @@ function initExternalConfig({ store, pym, inIframe }) {
}
return new Promise(resolve => {
pym.sendMessage('getConfig');
pym.onMessage('config', config => {
store.dispatch(mergeConfig(JSON.parse(config)));
pym.onMessage('config', rawConfig => {
const config = JSON.parse(rawConfig);
if (config.plugin_config) {
// @Deprecated
if (process.env.NODE_ENV !== 'production') {
console.warn(
'Deprecation Warning: `config.plugin_config` will be phased out soon, please replace `config.plugin_config with `config.plugins_config`'
);
}
config.plugins_config = config.plugin_config;
delete config.plugin_config;
}
store.dispatch(mergeConfig(config));
resolve();
});
});
@@ -215,6 +230,14 @@ export async function createContext({
pym.onMessage('logout', () => {
store.dispatch(logout());
});
pym.onMessage('enablePluginsDebug', () => {
store.dispatch(enablePluginsDebug());
});
pym.onMessage('disablePluginsDebug', () => {
store.dispatch(disablePluginsDebug());
});
}
const preInitList = [];
+29 -9
View File
@@ -11,7 +11,7 @@ import values from 'lodash/values';
import { getDisplayName } from 'coral-framework/helpers/hoc';
import camelize from '../helpers/camelize';
// This is returned for pluginConfig when it is empty.
// This is returned for pluginsConfig when it is empty.
const emptyConfig = {};
// Memoize the warnings so we only show them once.
@@ -73,10 +73,10 @@ function addMetaDataToSlotComponents(plugins) {
* query datas are only passed to the component if it is defined in `component.fragments`.
*/
function getSlotComponentProps(component, reduxState, props, queryData) {
const pluginConfig = get(reduxState, 'config.plugin_config') || emptyConfig;
const pluginsConfig = get(reduxState, 'config.plugins_config') || emptyConfig;
return {
...props,
config: pluginConfig,
config: pluginsConfig,
...(component.fragments
? pick(queryData, Object.keys(component.fragments))
: withWarnings(component, queryData)),
@@ -125,15 +125,16 @@ class PluginsService {
* Returns React Elements for given slot.
*/
getSlotElements(slot, reduxState, props = {}, options = {}) {
const pluginConfig = get(reduxState, 'config.plugin_config') || emptyConfig;
const pluginsConfig =
get(reduxState, 'config.plugins_config') || emptyConfig;
const { size = 0 } = options;
const { queryData, rest } = splitProps(props);
const isDisabled = component => {
if (
pluginConfig &&
pluginConfig[component.talkPluginName] &&
pluginConfig[component.talkPluginName].disable_components
pluginsConfig &&
pluginsConfig[component.talkPluginName] &&
pluginsConfig[component.talkPluginName].disable_components
) {
return true;
}
@@ -172,11 +173,30 @@ class PluginsService {
);
}
/**
* This adds a consistent keying for the slot elements.
* It uses the plugin name as the key. If the same plugin inserts
* multiple elements it will append `.${noOfOccurence}` to the
* key starting with the second element.
*/
const getKey = (() => {
const map = {};
return component => {
if (map[component.talkPluginName] === undefined) {
map[component.talkPluginName] = 0;
} else {
map[component.talkPluginName]++;
}
const i = map[component.talkPluginName];
return `${component.talkPluginName}${i > 0 ? `.${i}` : ''}`;
};
})();
return (size > 0 ? slots.slice(0, size) : slots)
.map((component, i) => ({
.map(component => ({
component,
disabled: isDisabled(component),
key: i,
key: getKey(component),
}))
.filter(o => !o.disabled)
.map(({ component, key }) =>
@@ -0,0 +1,8 @@
import LinkifyIt from 'linkify-it';
import tlds from 'tlds';
const linkify = new LinkifyIt();
linkify.tlds(tlds);
export default function matchLinks(text) {
return linkify.match(text);
}
+1 -1
View File
@@ -53,7 +53,7 @@ const CONFIG = {
process.env.TALK_LOGGING_LEVEL
)
? process.env.TALK_LOGGING_LEVEL
: 'info',
: process.env.NODE_ENV === 'test' ? 'fatal' : 'info',
// REVISION_HASH when using the docker build will contain the build hash that
// it was built at.
+2 -1
View File
@@ -2,4 +2,5 @@ public/*
!public/_redirects
.deploy*/
db.json
*.log
*.log
source/_data/introspection.json
+15 -7
View File
@@ -136,18 +136,26 @@ sidebar:
url: /plugins-directory/
- title: Plugin Recipes
url: /plugin-recipes/
- title: API
children:
- title: GraphQL Overview
url: /api/overview/
- title: GraphQL Reference
url: /api/graphql/
- title: Server Plugin API
url: /api/server/
- title: Client Plugin API
url: /api/client/
- title: Plugin Slots API
url: /api/slots/
- title: Tutorials
children:
- title: Creating a Basic Plugin
url: /building-basic-plugin/
- title: Customizing Plugins with Coral UI
url: /customizing-plugins-coral-ui/
- title: API
children:
- title: Server Plugins
url: /reference/server/
- title: GraphQL
url: /reference/graphql/
- title: When You've Installed Talk
url: /when-youve-installed-talk/
- title: Migrating
children:
- title: Migrating to v4.0.0
@@ -165,7 +173,7 @@ marked:
breaks: false
smartLists: true
smartypants: true
modifyAnchors: ''
modifyAnchors: 1
autolink: true
node_sass:
+3 -1
View File
@@ -1 +1,3 @@
/ /talk/
/ /talk/
/talk/reference/server/ /talk/api/server/
/talk/reference/graphql/ /talk/api/graphql/
+3 -3
View File
@@ -24,7 +24,7 @@ to persist data. The following versions are supported:
An optional dependency for Talk is
[Docker](https://www.docker.com/community-edition#/download).
It is used during [development](#development) to set up the database and can be
It is used during development to set up the database and can be
used to [install via Docker](#installation-from-docker). We have tested Talk
and this documentation with versions 17.06.2+.
@@ -80,7 +80,7 @@ volumes:
```
This is the bare minimum needed to run the demo, for more configuration
variables, check out the [Configuration](./configuration/) section.
variables, check out the [Configuration](/talk/configuration/) section.
And you can then start it with:
@@ -172,7 +172,7 @@ TALK_FACEBOOK_APP_SECRET=A-Facebook-App-Secret
```
This is only the bare minimum needed to run the demo, for more configuration
variables, check out the [Configuration](./configuration/) section. Facebook login above
variables, check out the [Configuration](/talk/configuration/) section. Facebook login above
will definitely not work unless you change those values as well.
@@ -75,7 +75,7 @@ volumes:
```
This is the bare minimum needed to start Talk, for more configuration
variables, check out the [Configuration](./configuration/) section.
variables, check out the [Configuration](/talk/configuration/) section.
And you can then start it with:
@@ -111,7 +111,7 @@ talk_1 yarn start Up 0.0.0.0:3000->3000/tcp
```
At this stage, you should refer to the [configuration](./configuration/) for
At this stage, you should refer to the [configuration](/talk/configuration/) for
configuration variables that are specific to your installation.
## Onbuild
@@ -142,7 +142,7 @@ This accomplishes a lot:
2. Installs any new dependencies that were required by any new plugins.
3. Builds the new static bundles so that they are ready to serve when the image
is running.
4. Specifies a build time variable [TALK_DEFAULT_LANG](./advanced-configuration/#talk_default_lang). Refer
4. Specifies a build time variable [TALK_DEFAULT_LANG](/talk/advanced-configuration/#talk-default-lang). Refer
to [Dockerfile.onbuild](https://github.com/coralproject/talk/blob/master/Dockerfile.onbuild) for the
available build variables.
@@ -62,7 +62,7 @@ TALK_FACEBOOK_APP_SECRET=A-Facebook-App-Secret
```
This is the bare minimum needed to start Talk, for more configuration
variables, check out the [Configuration](./configuration/)
variables, check out the [Configuration](/talk/configuration/)
section. Facebook login above will definitely not work unless you change those
values as well.
@@ -73,5 +73,5 @@ You can now start the application by running:
yarn watch:server
```
At this stage, you should refer to the [configuration](./configuration/) for
At this stage, you should refer to the [configuration](/talk/configuration/) for
configuration variables that are specific to your installation.
+2 -2
View File
@@ -16,7 +16,7 @@ instance of Talk.
If you've already configured your application with the required configuration,
you can further customize it's behavior by applying
[Advanced Configuration](./advanced-configuration/).
[Advanced Configuration](/talk/advanced-configuration/).
## TALK_MONGO_URL
@@ -81,4 +81,4 @@ TALK_JWT_SECRET=jX9y8G2ApcVLwyL{$6s3
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/) documentation.
refer to our [Advanced Configuration](/talk/advanced-configuration/) documentation.
+35 -32
View File
@@ -15,7 +15,7 @@ The variables above have defaults, and are _optional_ to start your
instance of Talk.
If this is your first time configuring Talk, ensure you've also added the
[Required Configuration](./configuration) as well,
[Required Configuration](/talk/configuration) as well,
otherwise the application will fail to start.
## TALK_CACHE_EXPIRY_COMMENT_COUNT
@@ -26,7 +26,7 @@ Configure the duration for which comment counts are cached for, parsed by
## TALK_DEFAULT_LANG
This is a **Build Variable** and must be consumed during build. If using the
[Docker-onbuild](./installation-from-docker/#onbuild)
[Docker-onbuild](/talk/installation-from-docker/#onbuild)
image you can specify it with `--build-arg TALK_DEFAULT_LANG=en`.
Specify the default translation language. (Default `en`)
@@ -34,7 +34,7 @@ Specify the default translation language. (Default `en`)
## TALK_DEFAULT_STREAM_TAB
This is a **Build Variable** and must be consumed during build. If using the
[Docker-onbuild](./installation-from-docker/#onbuild)
[Docker-onbuild](/talk/installation-from-docker/#onbuild)
image you can specify it with `--build-arg TALK_DEFAULT_STREAM_TAB=all`.
Specify the default stream tab in the admin. (Default `all`)
@@ -53,7 +53,7 @@ in the embed.js target that is loaded on the page that loads the embed. (Default
## 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)
This is used primarily in conjunction with [TALK_STATIC_URI](#talk-static-uri)
when the static assets are being hosted on an external domain. (Default `FALSE`)
## TALK_HELMET_CONFIGURATION
@@ -149,7 +149,7 @@ claim for login JWT tokens. (Default `talk`)
## 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) when logging out
[TALK_JWT_SIGNING_COOKIE_NAME](#talk-jwt-signing-cookie-name) when logging out
but will still blacklist the token. (Default `TRUE`)
## TALK_JWT_COOKIE_NAME
@@ -160,8 +160,8 @@ user. (Default `authorization`)
## 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)
and [TALK_JWT_SIGNING_COOKIE_NAME](#talk_jwt_signing_cookie_name) for this
default, we always use the value of [TALK_JWT_COOKIE_NAME](#talk-jwt-cookie-name)
and [TALK_JWT_SIGNING_COOKIE_NAME](#talk-jwt-signing-cookie-name) for this
value. Any additional cookie names specified here will be appended to the list
of cookie names to inspect.
@@ -183,13 +183,13 @@ Would mean we would check the following cookies (in order) for a valid token:
When `TRUE`, Talk will not verify or sign JWTs with an audience
[aud](https://tools.ietf.org/html/rfc7519#section-4.1.3)
claim, even if [TALK_JWT_AUDIENCE](#talk_jwt_audience) is set. (Default `FALSE`)
claim, even if [TALK_JWT_AUDIENCE](#talk-jwt-audience) is set. (Default `FALSE`)
## TALK_JWT_DISABLE_ISSUER
When `TRUE`, Talk will not verify or sign JWTs with an issuer
[iss](https://tools.ietf.org/html/rfc7519#section-4.1.1)
claim, even if [TALK_JWT_ISSUER](#talk_jwt_issuer) is set. (Default `FALSE`)
claim, even if [TALK_JWT_ISSUER](#talk-jwt-issuer) is set. (Default `FALSE`)
## TALK_JWT_EXPIRY
@@ -205,7 +205,7 @@ reason to create reasonable expiry lengths as to minimize the storage overhead.
## TALK_JWT_ISSUER
The issuer [iss](https://tools.ietf.org/html/rfc7519#section-4.1.1)
claim for login JWT tokens. (Defaults to value of [TALK_ROOT_URL](./configuration/#talk_root_url))
claim for login JWT tokens. (Defaults to value of [TALK_ROOT_URL](/talk/configuration/#talk-root-url))
## TALK_JWT_SECRET
@@ -223,22 +223,25 @@ You can also express this secret in the JSON syntax:
TALK_JWT_SECRET={"secret": "jX9y8G2ApcVLwyL{$6s3"}
```
Refer to the documentation for [TALK_JWT_ALG](#talk_jwt_alg) for other signing
Refer to the documentation for [TALK_JWT_ALG](#talk-jwt-alg) for other signing
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).
multiple keys, then refer to [TALK_JWT_SECRETS](#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
used, you do not need to specify a [TALK_JWT_SECRET](#talk_jwt_secret) as this
used, you do not need to specify a [TALK_JWT_SECRET](#talk-jwt-secret) as this
will take precedence. **The first secret in `TALK_JWT_SECRETS` will be used for
signing, and must contain a private key if used with an asymmetric algorithm.**
All secrets should specify a `kid` field which uniquely identifies a given key
and will sign all tokens with that `kid` for later identification.
and will sign all tokens with that `kid` for later identification. _If a token
is not signed with the `kid` field in the header, and multiple secrets are used,
the token will fail to be verified. This field must match what's provided to
Talk in the form of the `kid` field in the secret._
When the value of [TALK_JWT_ALG](#talk_jwt_alg) is a `HS*` value, then the value
When the value of [TALK_JWT_ALG](#talk-jwt-alg) is a `HS*` value, then the value
of the `TALK_JWT_SECRETS` should take the form:
```plain
@@ -247,24 +250,24 @@ TALK_JWT_SECRETS=[{"kid": "1", "secret": "my-super-secret"}, {"kid": "2", "secre
Note that the secret is stored in a JSON object, keyed by `secret`. This is only
needed when specifying in the multiple secrets for `TALK_JWT_SECRETS`, but may
be used to specify the single [TALK_JWT_SECRET](#talk_jwt_secret).
be used to specify the single [TALK_JWT_SECRET](#talk-jwt-secret).
When the value of [TALK_JWT_ALG](#talk_jwt_alg) is **not** a `HS*` value, then
When the value of [TALK_JWT_ALG](#talk-jwt-alg) is **not** a `HS*` value, then
the value of the `TALK_JWT_SECRETS` should take the form:
```plain
TALK_JWT_SECRETS=[{"kid": "1", "private": "<my private key>", "public": "<my public key>"}, ...]
```
Refer to the documentation on the [TALK_JWT_ALG](#talk_jwt_alg) for more
Refer to the documentation on the [TALK_JWT_ALG](#talk-jwt-alg) for more
information on what to store in these parameters.
## 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))
issued by Talk. (Defaults to value of [TALK_JWT_COOKIE_NAME](#talk-jwt-cookie-name))
## TALK_JWT_USER_ID_CLAIM
@@ -298,8 +301,8 @@ the websocket to keep the socket alive, parsed by
Setting a reCAPTCHA Public and Secret key will enable and require reCAPTCHA upon multiple failed login attempts.
Client secret used for enabling reCAPTCHA powered logins. If
[TALK_RECAPTCHA_SECRET](#talk_recaptcha_secret) and
[TALK_RECAPTCHA_PUBLIC](#talk_recaptcha_public) are not provided it will instead
[TALK_RECAPTCHA_SECRET](#talk-recaptcha-secret) and
[TALK_RECAPTCHA_PUBLIC](#talk-recaptcha-public) are not provided it will instead
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.
@@ -307,8 +310,8 @@ on getting an account setup.
## TALK_RECAPTCHA_SECRET
Server secret used for enabling reCAPTCHA powered logins. If
[TALK_RECAPTCHA_SECRET](#talk_recaptcha_secret) and
[TALK_RECAPTCHA_PUBLIC](#talk_recaptcha_public) are not provided it will instead
[TALK_RECAPTCHA_SECRET](#talk-recaptcha-secret) and
[TALK_RECAPTCHA_PUBLIC](#talk-recaptcha-public) are not provided it will instead
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.
@@ -347,7 +350,7 @@ by [ms](https://www.npmjs.com/package/ms). (Default `1 sec`)
## 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).
of the [TALK_ROOT_URL](/talk/configuration/#talk-root-url).
You would use this when your upstream proxy cannot strip the prefix from the
url. (Default `FALSE`)
@@ -395,12 +398,12 @@ 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
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))
[TALK_ROOT_URL](/talk/configuration/#talk-root-url))
## TALK_THREADING_LEVEL
This is a **Build Variable** and must be consumed during build. If using the
[Docker-onbuild](./installation-from-docker/#onbuild)
[Docker-onbuild](/talk/installation-from-docker/#onbuild)
image you can specify it with `--build-arg TALK_THREADING_LEVEL=3`.
Specify the maximum depth of the comment thread. (Default `3`)
@@ -414,13 +417,13 @@ 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
requests out of your CDN in order to serve traffic more efficiently.
If the value of [TALK_ROOT_URL](./configuration/#talk_root_url)
If the value of [TALK_ROOT_URL](/talk/configuration/#talk-root-url)
is a https url, then this defaults to `wss://${location.host}${MOUNT_PATH}api/v1/live`.
Otherwise, it defaults to `ws://${location.host}${MOUNT_PATH}api/v1/live`.
Where `MOUNT_PATH` is either `/` if [TALK_ROOT_URL_MOUNT_PATH](#talk_root_url_mount_path)
Where `MOUNT_PATH` is either `/` if [TALK_ROOT_URL_MOUNT_PATH](#talk-root-url-mount-path)
is `FALSE`, or the path component of
[TALK_ROOT_URL](./configuration/#talk_root_url) if it's `TRUE`.
[TALK_ROOT_URL](/talk/configuration/#talk-root-url) if it's `TRUE`.
**Warning: if used without managing the auth state manually, auth
cannot be persisted due to browser restrictions.**
@@ -491,7 +494,7 @@ be used with caution. (Default `FALSE`)
## TALK_ADDTL_COMMENTS_ON_LOAD_MORE
This is a **Build Variable** and must be consumed during build. If using the
[Docker-onbuild]({{ "/installation-from-docker/#onbuild" | relative_url }})
[Docker-onbuild](/talk/installation-from-docker/#onbuild)
image you can specify it with `--build-arg TALK_ADDTL_COMMENTS_ON_LOAD_MORE=10`.
Specifies the number of additional comments to load when a user clicks `Load More`. (Default `10`)
@@ -499,7 +502,7 @@ Specifies the number of additional comments to load when a user clicks `Load Mor
## TALK_ASSET_COMMENTS_LOAD_DEPTH
This is a **Build Variable** and must be consumed during build. If using the
[Docker-onbuild]({{ "/installation-from-docker/#onbuild" | relative_url }})
[Docker-onbuild](/talk/installation-from-docker/#onbuild)
image you can specify it with `--build-arg TALK_ASSET_COMMENTS_LOAD_DEPTH=10`.
Specifies the initial number of comments to load for an asset. (Default `10`)
@@ -507,7 +510,7 @@ Specifies the initial number of comments to load for an asset. (Default `10`)
## TALK_REPLY_COMMENTS_LOAD_DEPTH
This is a **Build Variable** and must be consumed during build. If using the
[Docker-onbuild]({{ "/installation-from-docker/#onbuild" | relative_url }})
[Docker-onbuild](/talk/installation-from-docker/#onbuild)
image you can specify it with `--build-arg TALK_REPLY_COMMENTS_LOAD_DEPTH=3`.
Specifies the initial replies to load for a comment. (Default `3`)
@@ -28,13 +28,13 @@ 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/)
and [Additional Plugins](./additional-plugins/) pages.
across the greater community. You can explore the plugins we offer by visiting our [Default Plugins](/talk/default-plugins/)
and [Additional Plugins](/talk/additional-plugins/) 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/)
You can explore the recipes we offer by visiting our [Plugin Recipes](/talk/plugin-recipes/)
page.
@@ -31,7 +31,7 @@ https://<your asset url>?commentId=<the comment id>
Talk supports by default 3 levels of threading, meaning each top-level comment
has a depth of 3 replies; replies beyond that are not nested below the 3rd
level. You can adjust this using the
[TALK_THREADING_LEVEL](./advanced-configuration/#talk_threading_level)
[TALK_THREADING_LEVEL](/talk/advanced-configuration/#talk-threading-level)
configuration variable. We dont recommend deep threading because it can cause
issues with styling, especially on mobile.
@@ -162,7 +162,7 @@ Staff role.
The Featured comment badge shows when a comment has been featured.
Another optional badge is the Subscriber badge (which is available as a
[Recipe](./plugin-recipes/#recipe-subscriber).
[Recipe](/talk/plugin-recipes/#recipe-subscriber).
Badges are another easy part of Talk to customize by creating a new `tag`, then
setting some rules for when it should show, and how the badge should be styled.
@@ -56,8 +56,8 @@ history.
**Toxic**
The Toxic badge signifies comments that are above the set Toxicity Probability
Threshold. Note you must have [talk-plugin-toxic-comments](./additional-plugins/#talk-plugin-toxic-comments) enabled.
[Read more about Toxic Comments here](./toxic-comments/).
Threshold. Note you must have [talk-plugin-toxic-comments](/talk/additional-plugins/#talk-plugin-toxic-comments) enabled.
[Read more about Toxic Comments here](/talk/toxic-comments/).
**Suspect**
@@ -122,7 +122,7 @@ automatically.
**Reports**
This shows if a commenter is a reliable flagger, an unreliable flagger, or a
neutral flagger. [Read more about reliable and unreliable flaggers here](./trust/#reliable-and-unreliable-flaggers).
neutral flagger. [Read more about reliable and unreliable flaggers here](/talk/trust/#reliable-and-unreliable-flaggers).
**Moderating from this View**
@@ -173,7 +173,7 @@ manage your team members roles (Admins, Moderators, Staff), as well as search
for commenters and take action on them (e.g. Ban/Un-ban, Suspend, etc.). ###
Configure
See [Configuring Talk](./configuring-talk/).
See [Configuring Talk](/talk/configuring-talk/).
## Moderating via the Comment Stream
+1 -1
View File
@@ -28,7 +28,7 @@ Here are the default thresholds:
+3 and higher: Reliable
```
You can configure your own Trust thresholds by using [TRUST_THRESHOLD](./advanced-configuration/#trust_thresholds) in your
You can configure your own Trust thresholds by using [TRUST_THRESHOLD](/talk/advanced-configuration/#trust-thresholds) in your
configuration.
@@ -50,7 +50,7 @@ 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)
[talk-plugin-toxic-comments](/talk/additional-plugins/#talk-plugin-toxic-comments)
plugin documentation.
@@ -0,0 +1,142 @@
---
title: What To Do When You've Installed Talk
permalink: /when-youve-installed-talk/
---
You've installed Talk on your server, and you're preparing to launch it on your site. The real community work starts now, before you go live. You have a unique opportunity pre-launch to set your community up for success.
**Contents:**
1. Take this opportunity for a fresh start
2. Publicly state the purpose and rules of your community
3. Decide where you will and won't put comments
4. Have clear moderation strategies
5. Get journalists on your side
6. Launch with care
### 1. Take this opportunity for a fresh start
The launch of a new tool is a great opportunity for a reset, to welcome in new community members, and to make clear what the space is for. [We have a ten-page workbook](https://guides.coralproject.net/workbook/) that you can download/print to help define your goals and vision for the community. It takes about 30 minutes to complete, asks clear, simple questions, and at the end you will have an outline of your community strategy to set you up for success.
### 2. Publicly state the purpose and rules of your community
If you don't launch with a clear strategy for your community, the most disruptive members will end up defining it for you. [Go here to learn how to create an effective community strategy.](https://guides.coralproject.net/write-a-community-mission-statement/) If your community is to succeed, you will need to make clear at the start what is and isn't acceptable, and enforce the rules clearly and consistently. [Read more about that here.](https://guides.coralproject.net/manage-a-successful-community/) Every successful community has an easy-to-read code of conduct, with a summary of the rules on every page that the comments appear. [Here's how to write your code.](https://guides.coralproject.net/create-a-code-of-conduct/)
In Talk, the summary of your community code goes into the box at the top of the comments. You enter that text by clicking on the Configure tab at the top, and scroll down to Include Comment Stream Description:
![[IMAGE] A screenshot of the Configure options in Talk, with a pink arrow pointing to the place where Comment Stream Description can be added](http://blog.coralproject.net/wp-content/uploads/2018/03/streamdescription.png)
### 3. Decide where you will and won't put comments
One the most important lessons we wish more newsrooms understood is this: **on-site comments don't have to be all or nothing.**
If your goal is to create a civil, productive space for online discourse, you should only make promises about the space that you can keep. If you have very few resources to dedicate to your community, that might mean only opening a small number of articles for discussion each day or only having a weekly comments discussion about the week's news, similar to [the Guardian Social's Catch Up of the Week where they interact in, and highlight the best of, the comments.](https://www.theguardian.com/commentisfree/live/2017/apr/21/how-do-you-feel-about-another-general-election-join-our-live-look-at-the-week)
Some topics that you cover will be more challenging to create civil discourse around than others, and opening the comments on them could require a lot of hands-on moderation to ensure the kinds of interactions that you want we've found in the U.S. that conversations around issues of race, immigration, and breaking news involving potential assailants or terrorism, can quickly break down and lead to abusive and negative interactions. You will know best which are the most controversial topics in your community, and [you can model the threats you are likely to face as a result.](https://guides.coralproject.net/threat-modeling-for-communities/) For these topics, we recommend that you have a plan ahead of time to address the problems that are likely to come up.
Your plans might include:
* Watching the comments on these articles carefully, and posting a public note using the 'Ask a Question' box stating that you will delete comments, suspend/ban people, or close comments altogether if the conversation devolves (and then following through on that.) Here's where you can add that text from the article page:
![[IMAGE] A screenshot of the Configure menu from the comments view](http://blog.coralproject.net/wp-content/uploads/2018/03/ask2.png)  
* Setting the comments on these articles to pre-moderation, then writing a note in the Ask a Question box stating that all comments have to be approved before publication; The Washington Post does this in some breaking news situations. You can set the comments on a single story to pre-moderation via the Configure tab on the article's comments. 
![[IMAGE] A screenshot of the pre-moderate option in the Configure tab](http://blog.coralproject.net/wp-content/uploads/2018/03/premod.png)
* Not allowing comments on these topics at all. Following a series of racist responses, for instance, the CBC in Canada chose not to open comments at all on articles about indigenous Canadians. That's a perfectly valid response if you don't have the resources to ensure a civil discussion if people really want to talk about it, they can go elsewhere.In this situation, we recommend publishing a note at the bottom of all articles where this applies, explaining your decision and directing people to where they can send letters to the editor or continue the discussion off site, so that your community members understand what is happening.
Other options for contentious topics instead of comments:
* Use a form to request answers on a specific question related to the article, and select only the best answers for display. This is how the Spotlight team at Boston Globe solicited responses to their series on racism in Boston, using our Ask tool. ([Learn more about Ask here.](https://coralproject.net/products/ask.html)) This also allows people to submit their thoughts anonymously.
* Host a focused, more controlled online discussion about the topic over a fixed period of time, during which you apply more vigilant moderation than usual. You can write a note in the Ask A Question space described above to make that clear, such as "We will host an online conversation about this topic here on Friday between 11am and 3pm EST. Moderators will be present throughout, and our journalists will answer your questions about the topic."This allows community members to approach the topic more calmly than their initial reactions on reading the story, in a space where you can dedicate temporary, more intensive resources to ensuring civility (perhaps with pre-moderation), while still signaling your commitment to engagement around an important topic.
### 4. Have clear moderation strategies
The most important predictors of the success of an online community are:
* **Does everyone understand and agree with the basic rules?**
* **Are the rules visibly enforced?** No matter how benign the topic might seem, disruptive behavior will occur in your community (we've seen online communities about classical music and bonsai tree ownership become hotbeds of abuse and aggression.) Whether or not the behavior repeats in your community depends on your response. Once you've created your code of conduct and displayed it clearly (see above), you then need to dedicate resources to moderating your communities **quickly, effectively, and consistently.**
#### Quickly:
* Create Banned/Suspect word lists specific to your needs, to prevent the worst words being posted, and to auto-report comments you should keep an eye on.![](http://blog.coralproject.net/wp-content/uploads/2018/03/banned-suspect.jpg)
**We have a starter list of more than 1700 words/phrases that most sites choose to ban.** Email support@coralproject.net to request it.
* If you don't have a very high comment volume and use Slack in your newsroom, you could integrate Slack moderation to keep tabs on new/reported comments. [Read more about our free Slack moderation plugin.](https://blog.coralproject.net/slacking-on/)
* Utilize [our Toxic Comments plugin](https://blog.coralproject.net/toxic-avenging/), developed with Google Jigsaw, to improve commenter behavior and use AI to help identify and prevent the most abusive comments from appearing on your site.
* Enable our [Akismet plugin](https://coralproject.github.io/talk/additional-plugins/#talk-plugin-akismet) to keep spam from appearing in your comments.
* Use keyboard shortcuts in the moderation queue to moderate quickly (type '?' in the moderation view to see the list of shortcuts), and if there is a sudden deluge of comments, ask for someone in your newsroom to help you moderate. Talk will notify you in the moderation interface if someone else moderates a comment that's already on your screen.
* Click on a community member's name in the moderation interface to review all their comments, and see if there is a clear pattern of abuse among their Rejected comments. You can also select all their recent comments and delete them in bulk.![A screenshot of the moderation interface with the user drawer on display. Pink arrows indicate the user name, which can be clicked to open the drawer, and the drawer itself.](http://blog.coralproject.net/wp-content/uploads/2018/03/userdrawer.jpg)
* Give people who need to step away from the conversation a 'time out' by suspending their account via the User drawer (click on the user's name anywhere in the moderation interface), and write a personalized note to them to explain why you took this action. Ban any users whose behavior is clearly offensive and/or abusive to an extreme degree.
![A close up of the User Drawer with a large pink arrow indicating the Actions menu where users can be suspended or banned](http://blog.coralproject.net/wp-content/uploads/2018/03/suspend-ban.jpg)
* Reject the worst comments on the article page itself by using the small caret in the corner of each comment.
![A screenshot of the comments stream with a moderation menu popped out and a large pink arrow pointing to the caret where moderators can unfold the moderation menu](http://blog.coralproject.net/wp-content/uploads/2018/03/caret-mod.jpg)
* If you find new commenters are causing a lot of trouble with their first comments, consider changing [the User Karma threshold](https://coralproject.github.io/talk/trust/) from negative one to zero, forcing every new commenter's first comment into pre-moderation.
#### Effectively
* If you're using the Toxic Comments plugin, make sure that its threshold is set at the level that catches most comments with fewest false positives (default is 80%). You can see the Likely to be Toxic level of every comment by clicking "More Details" on the comment card in the moderation view.
* Publicly discourage behavior in the comments that doesn't cross the line but suggests that the tone or focus could shift quickly in a direction you don't want. Point to relevant sections of your community guidelines. [Read more about defining and discouraging this kind of behavior here.](https://guides.coralproject.net/manage-a-successful-community/)
* If people are sharing links to conspiracy sites or other unreliable sources, consider setting either the article (via the Configure tab on the article page) or the whole site (via Configure in the Admin view) to Pre-moderate Links, and delete any comments that link to sites that exceed your guidelines.
![An image showing the Pre-moderate Links option in the moderation console](http://blog.coralproject.net/wp-content/uploads/2018/03/premodlinks-site.jpg)
![A screenshot of the Configure tab in the comments stream with the Pre-Moderate Links option circled in pink](http://blog.coralproject.net/wp-content/uploads/2018/03/premodllinks-article.jpg)
* If the conversation is getting out of hand, set the article to Pre-moderation (via the Configure tab on the comments, see above) and tell your community members you've done it, via Ask a Question (see above). If the conversation doesn't improve, or you feel that it is beyond redemption, consider closing the article to comments altogether via the Configure tab or the Stories tab in the moderation view, and telling the community why you have done so.
* Make sure that your community has an opportunity to give feedback and shape your guidelines. Create a page on your site for meta-discussions about your policies this could either be one static page, or an ongoing series of updates (see below). Encourage your community to interrogate your standards, and participate in improving them. This will help give your community a sense of co-ownership over the space, and encourage them to help enforce its codes.
#### Consistently
* Create [a clear series of guidelines](https://guides.coralproject.net/create-a-code-of-conduct/) that your community members can easily reference, and [set up your moderation strategy ahead of time.](https://guides.coralproject.net/how-to-moderate-effectively/)
* When you ban/suspend a community member, include language in the email they are sent through Talk that explains which aspect/s of your guidelines they have ignored.
* Make sure that any new member of your moderation team receives training before they begin, that you make sure that everyone in your moderation team is watching each other [for signs of secondary trauma](https://www.counseling.org/docs/trauma-disaster/fact-sheet-9---vicarious-trauma.pdf), and that everyone knows they can step away at any time if things get difficult. [Here's a piece on how to create an effective moderation team.](https://guides.coralproject.net/creating-a-successful-community-management-team/) [You can read more about the emotional labor of moderation here.](https://guides.coralproject.net/supporting-emotional-labor-in-moderation/)
### 5. Get journalists on your side
Most journalists don't like comments. [Also, most journalists read comments.](https://mediaengagement.org/research/journalists-and-online-comments/)
If your goal is to bring community closer to your journalism, you need to change the minds of people in your newsroom about the value of your onsite community. There are two reasons to do this: to improve the community, and to improve the journalism.
**For the community:** [As a study from the Center for Media Engagement (CME) shows](https://mediaengagement.org/research/journalist-involvement/), comments are more civil when a journalist engages in the space. [A separate study that we commissioned from the CME](https://mediaengagement.org/research/comment-section-survey-across-20-news-sites/) demonstrates that the majority of commenters across sites of all sizes want journalists to engage in the comments.
**For the journalism:** [there is real potential value in the comments](https://guides.coralproject.net/why-community-work-is-important/), in helping journalists find tips and sources, in finding important clarifications and corrections, in building a loyal audience, and in involving your community in your mission. These are some of your most dedicated readers. They deserve your attention. That said, journalists need to be prepared for how to engage effectively.
[As this guide on engaging in the comments](http://niemanreports.org/articles/getting-the-most-out-of-comments-a-guide-for-journalists/) states, the main principles for how to act in the comments should be
* **Thank**
* **Engage** (through Featured comments, replying to the commenter)
* **Share** (via social media and, where appropriate, in follow-up articles)
If you are going to ask journalists moderate comments, we recommend that they don't moderate their own but instead pair with someone else to moderate each other's, before the author of the piece goes into the comments to engage. In that way, the worst abuse and criticism can be removed by someone who is less likely to take it personally. You should also instruct journalists on how to escalate potentially credible threats to a senior editor, and make it clear that it's ok to step away if the task starts to affect them personally. [There are more tips on supporting the emotional health of people who moderate comments here.](https://guides.coralproject.net/supporting-emotional-labor-in-moderation/)
### 6. Launch with care
We strongly recommend launching Talk on just one article, talking about the change that will be coming to the rest of the site, describing the features, and letting your community kick the tires on the new system before it is released everywhere. Doing this will allow your community members to get used to the change, to make suggestions, to enter into conversation with you about the switch, and to help make them feel included and less surprised about the change when it goes sitewide. It will also allow you to get used to the moderation interface without being overwhelmed. [Here's how The Washington Post used their community to test the new system.](https://www.washingtonpost.com/news/ask-the-post/2017/06/15/everybody-talk-round-2-of-testing-for-the-coral-projects-comment-software/?utm_term=.10f68bbb671a)
If you can't release Talk in this way, we recommend that you announce the launch of the new system in a standalone article, describing the features in Talk (especially 'Ignore User', My Profile, Notifications, and Report functions. Screenshots with arrows can help - if you're a Mac user, try [Skitch](https://evernote.com/products/skitch)), explaining why you've moved to Talk, describing the benefits Talk brings, and the changes you will be looking for/promises you will make to the community moving forward. You should take more time than usual to guide people, answer questions, and collect suggestions for improvements (we'd love to hear them.) [Here's how The Intercept did this.](https://theintercept.com/2017/12/18/comments-coral-project/)
For the first month or so, we recommend including a link to the launch article mentioned above in the Comments Stream Description box on every Talk page. This will give community members a place to go to discuss the new system, so that they are more likely to be on topic on the articles themselves. We continue to add more features to Talk every few weeks. For significant feature changes, we suggest writing a follow-up article to introduce them to your community, pointing out the features and encouraging more feedback on the system itself, and on how you are managing the community.
A regular space for conversation about the conversation is always welcome in any successful community, and is a great source of ideas for improvement.  
**_We hope you enjoy using Talk, and that it helps your communities to thrive. If you have any questions or suggestions for this piece, or would like to try Talk on your site, please [contact us.](https://coralproject.net/contact.html)_**  
File diff suppressed because it is too large Load Diff
+1 -7
View File
@@ -13,8 +13,6 @@
- default
- name: talk-plugin-comment-content
description: Linkifies comment content that contains links.
tags:
- default
- name: talk-plugin-deep-reply-count
description: Enables deep reply count graph edge including all decendant replies.
- name: talk-plugin-facebook-auth
@@ -79,8 +77,6 @@
- notifications
- name: talk-plugin-offtopic
description: Allows users to mark a comment as off-topic when they create it.
tags:
- default
- name: talk-plugin-permalink
description: Shows a Link button on comments for direct-linking to a comment.
tags:
@@ -94,8 +90,6 @@
- reaction
- name: talk-plugin-rich-text
description: Enables rich text plugins that save data as HTML.
- name: talk-plugin-rich-text-pell
description: Enables the pell rich text editor.
- name: talk-plugin-slack-notifications
description: Sends all comments as notifications to a slack channel
- name: talk-plugin-sort-most-liked
@@ -139,4 +133,4 @@
description: Enables the dropdown used to display sorting options on the embed stream.
tags:
- default
- sorting
- sorting
+301
View File
@@ -0,0 +1,301 @@
---
title: Client Plugin API
permalink: /api/client/
toc: true
class: configuration
---
We created a set of utilities to make it easier to create and add functionality to plugins.
Feel free to check all the utilities here: `talk/plugin-api`.
## Actions
#### Admin
* `viewUserDetail`
#### Auth
* `setAuthToken`
* `handleSuccessfulLogin`
* `logout`
#### Notification
* `notify`
#### Stream
* `setSort`
* `showSignInDialog``
### Import
```
import {notify} 'plugin-api/beta/actions';
```
### Usage
```js
// Trigger a notification
notify('success', t('suspenduser.notify_suspend_until', username, timeago(until))
// mapDispatchToProps
const mapDispatchToProps = dispatch => ({
...bindActionCreators(
{
notify,
},
dispatch
),
});
```
## Components
* `Slot`
You probably wont need to use the `<Slot/>` component in your plugin. But theres a chance you might want to add a Slot so another plugin gets injected in your plugin.
### Props
* `fill ` : <String | Array> Name of the slot
* `defaultComponent` : <Element | Array> The default component if no plugin component is provided to the Slot
* `size` : <Number | Array> - How many components this Slot should show - Slot size or an Array of slot size
* `passthrough`: <Object> - The properties that you want to pass to the Slot, therefore to the plugins.
* `className` : <String> - Slots class name
### Import
```
import {Slot} 'plugin-api/beta/components';
```
### Usage
```js
const slotPassthrough = {
clearHeightCache,
root,
asset,
comment,
};
<Slot
fill="adminCommentContent"
className={className}
defaultComponent={CommentFormatter}
size={1}
passthrough={slotPassthrough}
/>
```
* `IfSlotIsEmpty`
### Import
```
import {IfSlotIsEmpty} 'plugin-api/beta/components';
```
### Usage
```js
<IfSlotIsEmpty
slot="adminCommentContent"
passthrough={slotPassthrough}
/>
```
* `IfSlotIsNotEmpty`
### Import
```
import {IfSlotIsNotEmpty} 'plugin-api/beta/components';
```
### Usage
```js
<IfSlotIsNotEmpty
slot="adminCommentContent"
passthrough={slotPassthrough}
/>
```
* `ClickOutside`
This utility handle click events outside the component.
### Props
* `onClickOutside` : Takes handler function
#### Import
```js
import { ClickOutside } from 'plugin-api/beta/client/components';
```
#### Usage
```js
<ClickOutside onClickOutside={this.handleClickOutside}>
// Your component
</ClickOutside>
```
* `CommentAuthorName`
* `CommentTimestamp`
* `CommentDetail`
* `CommentContent`
* `ConfigureCard`
* `StreamConfiguration`
* `Recaptcha`
## HOCS - Higher Order Components
*`withGraphQLExtension`*
This HOC allows components to register GraphQLExtensions for the framework. IMPORTANT: The extensions are only picked up when the component is used in a slot.
### Import
```js
import { withGraphQLExtension } from 'plugin-api/beta/hoc';
```
### Usage
```js
withGraphQLExtension({
mutations: {
UpdateNotificationSettings: () => ({
update: proxy => {...}
})
},
fragments: {...},
query: {...},
})(MyComponent);
```
And then update your `my-plugin/client/index.js`
```js
export default {
mySlot: [MyComponent],
}
```
* `withReaction`
Provides you utilities to create components that interact with Reactions.
Check this tutorial to know more about the usage of `withReaction` [Creating a Basic Pride Reaction Plugin | Talk Documentation](https://docs.coralproject.net/talk/building-basic-plugin/)
### Import
```js
import { withReaction } from 'plugin-api/beta/hoc';
```
### Usage
```js
export default withReaction('pride')(PrideButton);
```
* `withTags`
Provides you utilities to create components that interact with Tags.
### Import
```js
import { withTags } from 'plugin-api/beta/hoc';
```
### Usage
```js
export default withTags('featured')(FeaturedButton);
```
* `withSortOption`
* `withEmit`
* `excludeIf`
* `withFragments`
* `withMutation`
* `withForgotPassword`
* `withSignIn`
* `withSignUp`
* `withResendEmailConfirmation`
* `withSetUsername`
* `withEnumValues`
* `withVariables`
* `withFetchMore`
* `withSubscribeToMore`
* `withRefetch`
* `withIgnoreUser`
* `withBanUser`
* `withUnbanUser`
* `withStopIgnoringUser`
* `withSetCommentStatus`
* `compose`
## Services
* `t`
To manage translations.
### Import
```js
import { t } from 'coral-framework/services/perms';
```
* `timeago`
Handle time with [timeago](https://github.com/hustcc/timeago.js)
### Import
```js
import { timeago } from 'coral-framework/services/perms';
```
* `can`
A permissions utility.
### Import
```js
import { can } from 'coral-framework/services/perms';
```
### Usage
```js
{can(currentUser, 'UPDATE_CONFIG') && (
<Link
className={cn('talk-admin-nav-configure', styles.navLink)}
to="/admin/configure"
activeClassName={styles.active}
>
{t('configure.configure')}
</Link>
)}
```
## Coral UI
Coral UI is a set of components to help you build your UI. This powers our core.
### Import
```js
import {Button} 'plugin-api/beta/components/ui';
```
### Components
* `Alert`
* `Dialog`
* `CoralLogo`
* `FabButton`
* `TabBar`
* `Tab`
* `TabCount`
* `TabContent`
* `TabPane`
* `Button`
* `Spinner`
* `Tooltip`
* `PopupMenu`
* `Checkbox`
* `Icon`
* `List`
* `Item`
* `Card`
* `TextField`
* `Success`
* `Paginate`
* `Wizard`
* `WizardNav`
* `SnackBar`
* `TextArea`
* `Drawer`
* `Label`
* `FlagLabel`
* `Dropdown`
* `Option`
* `BareButton`
+15
View File
@@ -0,0 +1,15 @@
---
title: GraphQL API Reference
permalink: /api/graphql/
---
We provide all services that Talk can provide via the GraphQL API documented
below. For a primer about GraphQL, visit http://graphql.org/.
If you're already familiar with GraphQL, visit
[GraphQL API Overview](/talk/api/overview/) to see how to
interact with Talk's GraphQL endpoint.
# GraphQL Schema
{% graphqldocs _data/introspection.json %}
+196
View File
@@ -0,0 +1,196 @@
---
title: GraphQL API Overview
permalink: /api/overview/
---
We provide all services that Talk can provide via the GraphQL API documented
on our [GraphQL API Reference](/talk/api/graphql/). If you've never heard
about GraphQL before, visit http://graphql.org/ to learn the basics first.
## Development
During development mode (when Talk has `NODE_ENV=development`) Talk will enable
the GraphiQL IDE at the following route:
${ROOT_URL}api/v1/graph/iql
This is pretty powerful, as it lets you explore the API documentation on the
sidebar as well as send off requests.
## Making your first request
To learn a bit about how to interact with Talk, we'll query for comments on a
page of Talk. I have Talk running locally, (If you don't and want to, checkout
our [Talk Quickstart](/talk/)).
The GraphQL endpoint we have can be used with any HTTP client available, but our
examples below will use the common `curl` tool:
```sh
curl --request POST \
--url http://localhost:3000/api/v1/graph/ql \
--header 'Content-Type: application/json' \
--data '{"query":"query GetComments($url: String!) { asset(url: $url) { title url comments { nodes { body user { username } } } }}","variables":{"url":"http://localhost:3000/"},"operationName":"GetComments"}'
```
When you unpack that, it's really quite simple. We're executing a `POST` request
to the `/api/v1/graph/ql` route of the local Talk server with the GraphQL
request we want to make. It's composed of the `query`, `variables`, and
`operationName`.
```graphql
query GetComments($url: String!) {
asset(url: $url) {
title
url
comments {
nodes {
body
user {
username
}
}
}
}
}
```
The query itself is quite straightforward, we are grabbing the asset with the
specified `$url`, and grabbing it's title and the comments also (You can also
look at our [GraphQL API Reference](/talk/api/graphql/) for our entire schema).
We can then also specify our variables to the query being executed (in this
case, the url for the page where we have comments on our local install of Talk):
```json
{
"url": "http://localhost:3000/"
}
```
It's also sometimes common to have multiple queries within a query, which is
where the `operationName` comes into play, where we simply specify the named
query that we want to execute (in this case, `GetComments`).
To get a deeper understanding of GraphQL queries, read up on
[GraphQL Queries and Mutations](http://graphql.org/learn/queries/).
## Understanding the response
Once you completed the above GraphQL query with `curl`, you'll get a response
sort of like this:
```json
{
"data": {
"asset": {
"title": "Coral Talk",
"url": "http://localhost:3000/",
"comments": {
"nodes": [
{
"body": "Second comment!",
"user": {
"username": "wyattjoh"
}
},
{
"body": "First comment!",
"user": {
"username": "wyattjoh"
}
}
]
}
}
}
}
```
All of the parameters you requested should be available under the `data`
property. Any errors that you get would appear in a `errors` array at the top
level, like this:
```json
{
"data": {
"asset": null
},
"errors": [
{
"message": "asset_url is invalid",
"locations": [
{
"line": 2,
"column": 3
}
],
"path": [
"asset"
]
}
]
}
```
You should know that any property that is marked with a `!` is considered
required, and non-nullable, which means you can always guarantee on it being
there in your request if there were no errors.
## Authorizing a request
Some queries you may notice seem to return `null` or an error of
`NOT_AUTHORIZED`. It's likely the case that you are making a request to a
route that requires authorization. You can perform authorization a few ways in
Talk:
1. As a [Bearer Token](#Bearer-Token)
2. As a [Query Parameter](#Query-Parameter)
3. As a [Cookie](#Cookie)
Essentially, you need to get access to a JWT token that you can use to authorize
your requests. Generating one is simple, you can use the CLI tools in Talk to do
that.
```sh
# first, find your user account
./bin/cli users list
# then, create a token for that account
./bin/cli token create ${USER_ID} example-token
```
Where `USER_ID` is the ID of your user account you found using the `users list`
command.
Once you have your access token, you can substitute it as `${TOKEN}` in your
`curl` request as follows:
### Bearer Token
```sh
curl --request POST \
--url http://localhost:3000/api/v1/graph/ql \
--header 'Content-Type: application/json' \
--header "Authorization: Bearer ${TOKEN}"
--data '{"query":"query GetComments($url: String!) { asset(url: $url) { title url comments { nodes { body user { username } } } }}","variables":{"url":"http://localhost:3000/"},"operationName":"GetComments"}'
```
### Query Parameter
```sh
curl --request POST \
--url http://localhost:3000/api/v1/graph/ql?access_token=${TOKEN} \
--header 'Content-Type: application/json' \
--data '{"query":"query GetComments($url: String!) { asset(url: $url) { title url comments { nodes { body user { username } } } }}","variables":{"url":"http://localhost:3000/"},"operationName":"GetComments"}'
```
### Cookie
```sh
curl --request POST \
--url http://localhost:3000/api/v1/graph/ql \
--header 'Content-Type: application/json' \
--cookie "authorization=${TOKEN}"
--data '{"query":"query GetComments($url: String!) { asset(url: $url) { title url comments { nodes { body user { username } } } }}","variables":{"url":"http://localhost:3000/"},"operationName":"GetComments"}'
```
@@ -1,6 +1,6 @@
---
title: Server Plugin API
permalink: /reference/server/
permalink: /api/server/
toc: true
class: configuration
---
@@ -377,7 +377,11 @@ en:
Which overrides the copy for the `embedlink.copy` template. You can
also provide other languages as well by using the correct language
prefix.
prefix.
When creating a plugin using this `translations` hook to override copy
from another plugin, be sure to list it after the plugin it's overriding
in the `plugins.json` file.
### websockets
@@ -524,4 +528,4 @@ module.exports = {
}
};
```
```
+171
View File
@@ -0,0 +1,171 @@
---
title: Plugin Slots API
permalink: /api/slots/
toc: true
class: configuration
---
Plugins make use of **"slots"** in order to change Talk's interface.
By default, Talk has various plugins provided by default. We can see this in `plugins.default.json`:
```json
{
"server": [
"talk-plugin-auth",
"talk-plugin-featured-comments",
"talk-plugin-offtopic",
"talk-plugin-respect"
],
"client": [
"talk-plugin-auth",
"talk-plugin-author-menu",
"talk-plugin-comment-content",
"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-viewing-options",
"talk-plugin-profile-settings"
]
}
```
Let's only focus on the plugins which are listed under `client` - these are the plugins that use *slots* to inject certain functionality into the Talk UI.
For example, if we look at the Respect plugin (`talk-plugin-respect`), we can see its `client/index.js` looks like this:
```js
import RespectButton from './RespectButton';
import translations from './translations.yml';
export default {
translations,
slots: {
commentReactions: [RespectButton],
},
};
```
Inside the `slots` property, we specify which **slots** the plugin will use. Above we are saying that the `RespectButton` component is being injected into the slot `commentReactions`.
Slots can receive an Array of components, so we can use one plugin or many for one slot.
### Anatomy of the Slot Component
In Talk core, we have 32 slots available for us to use. The component `Slot` has a `fill` property where we establish the name of the slot. It looks like this:
```js
<Slot
fill="commentReactions"
{...props}
/>
```
You won't have to use this to build plugins, but it's helpful to find where to embed your plugin.
### Slot List
* `adminCommentDetailArea`
* `adminCommentMoreDetails`
* `adminCommentLabels`
* `adminModerationSettings`
* `adminStreamSettings`
* `adminTechSettings`
* `adminCommentInfoBar`
* `adminCommentContent`
* `adminSideActions`
* `adminModeration`
* `adminModerationIndicator`
* `commentInputDetailArea`
* `commentAvatar`
* `commentAuthorName`
* `commentAuthorTags`
* `commentTimestamp`
* `commentInfoBar`
* `commentContent`
* `commentReactions`
* `commentActions`
* `commentInputArea`
* `draftArea`
* `streamSettings`
* `historyCommentTimestamp`
* `profileSections`
* `embed`
* `stream`
* `streamFilter`
* `streamQuestionArea`
* `login`
* `userProfile`
* `userDetailCommentContent`
### Where should I insert my plugin?
The first thing we should consider is what components will be affected by our plugin's functionality. For example, if we want to add functionality to all the comments that are rendered in a total list of comments, we would use the component `Comment`.
The slots that are able to add functionality to comments start with `comment`, like `commentContent`, or `commentReactions`, as you can see above.
### Disabling plugins via `plugins_config`
Typically, you will manage plugins via your `plugins.json` file. If you want to remove a plugin, you would simply delete it there. However, we can also do this directly with the `plugins_config`.
Let's look at our example article, `views/article.ejs`. Here we see we have the Talk embed, and within the embed, we can also send a configuration object. To disable a plugin visually, we can pass `true` to the property `disable_components`. Like so:
```js
plugins_config: {
'talk-plugin-love': {
disable_components: true,
},
}
```
### Sending information to slots and plugins
Inside our `plugins_config`, we can also send properities and our plugins will receive them. For example, if we send this:
```js
plugins_config: {
test: 'data'
}
```
The plugin will receive a config object with the properties we've passed. If we do a `console.log` with `this.props`, we would see:
```js
config: {test: 'data'}
```
### Debugging slots and plugins
You can debug slots and plugins simply by passing the `debug` property with value `true`:
```js
plugins_config: {
debug: true
}
```
This will turn on a visual aid to show you all of Talk's available slots and their names. Just move your mouse around!
### Slot ClassNames
Slots autogenerate their classes with the prefix `talk-slot`, followed by the name of the slot in kebab case.
For example, the class autogenerated for the slot `commentContent` is `talk-slot-comment-content`.
+1 -1
View File
@@ -116,4 +116,4 @@ configuration and will ensure that the image is ready to use by building all
assets inside the image as well.
For more information on the onbuild image, refer to the
[Installation from Docker](./installation-from-docker/) documentation.
[Installation from Docker](/talk/installation-from-docker/) documentation.
-9
View File
@@ -1,9 +0,0 @@
---
title: GraphQL API
permalink: /reference/graphql/
---
We provide all services that Talk can provide via the GraphQL API documented
below. For a primer about GraphQL, visit http://graphql.org/.
{% graphqldocs ../../client/coral-framework/graphql/introspection.json %}
+1 -1
View File
@@ -17,7 +17,7 @@
<ul class="sidebar__links">
{% for item in item.children %}
<li class="{% if is_current(item.url) %}active{% endif %}">
<a href="{% if !is_current(item.url) %}{{ url_for(item.url) }}{% else %}#{% endif %}">{{ item.title }}</a>
<a href="{{ url_for(item.url) }}">{{ item.title }}</a>
</li>
{% endfor %}
</ul>
+2 -2
View File
@@ -3,7 +3,7 @@
const path = require('path');
const fs = require('fs');
const { stripIndent } = require('common-tags');
const docs = fs.readFileSync(
const docsScript = fs.readFileSync(
require.resolve('graphql-docs/dist/graphql-docs.min.js'),
{ encoding: 'utf8' }
);
@@ -16,7 +16,7 @@ hexo.extend.tag.register('graphqldocs', args => {
<div id="graphql-docs"></div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/15.0.2/react.min.js" integrity="sha256-oj3q2t3QPvtdjo4M5gZfrAXyHEfTfvYdfRL2jA2ZfOY=" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/15.0.2/react-dom.min.js" integrity="sha256-sqgMIZkGTh7B/tF2nSyXc+tGBYCsfWiTl2II167jrOQ=" crossorigin="anonymous"></script>
<script>${docs}</script>
<script>${docsScript}</script>
<script>
function fetcher() {
return new Promise(function(resolve) {
+11
View File
@@ -97,6 +97,16 @@ const ErrEmailVerificationToken = new APIError('token is required', {
status: 400,
});
// ErrEmailAlreadyVerified is returned when the user tries to verify an email
// address that has already been verified.
const ErrEmailAlreadyVerified = new APIError(
'email address is already verified',
{
translation_key: 'EMAIL_ALREADY_VERIFIED',
status: 409,
}
);
// ErrPasswordResetToken is returned in the event that the password reset is requested
// without a token.
const ErrPasswordResetToken = new APIError('token is required', {
@@ -284,6 +294,7 @@ module.exports = {
ErrCommentTooShort,
ErrContainsProfanity,
ErrEditWindowHasEnded,
ErrEmailAlreadyVerified,
ErrEmailTaken,
ErrEmailVerificationToken,
ErrInstallLock,
+32 -4
View File
@@ -42,6 +42,32 @@ const decorateContextPlugins = (context, contextPlugins) => {
);
};
/**
* Some pieces of the Context are quite complex to setup, using multiple merges
* and other lodash functions. This proxies that access such that it is only
* loaded if it is used. Helpful for a query that only uses a loader, and not a
* mutator.
*
* @param {Object} ctx the graph proxy
* @param {Function} loader the loadable component that should be proxied
*/
const createLazyContextLoader = (ctx, loader) =>
new Proxy(
{ loaded: false, data: null },
{
get: (obj, prop) => {
if (obj.loaded) {
return obj.data[prop];
}
obj.data = loader(ctx);
obj.loaded = true;
return obj.data[prop];
},
}
);
/**
* Stores the request context.
*/
@@ -61,16 +87,18 @@ class Context {
this.connectors = connectors;
// Create the loaders.
this.loaders = loaders(this);
this.loaders = createLazyContextLoader(this, loaders);
// Create the mutators.
this.mutators = mutators(this);
this.mutators = createLazyContextLoader(this, mutators);
// Decorate the plugin context.
this.plugins = decorateContextPlugins(this, contextPlugins);
this.plugins = createLazyContextLoader(this, () =>
decorateContextPlugins(this, contextPlugins)
);
// Bind the publish/subscribe to the context.
this.pubsub = getBroker();
this.pubsub = createLazyContextLoader(this, () => getBroker());
// Bind the parent context.
this.parent = ctx;
+5 -1
View File
@@ -1,5 +1,6 @@
const {
makeExecutableSchema,
addResolveFunctionsToSchema,
addSchemaLevelResolveFunction,
} = require('graphql-tools');
const debug = require('debug')('talk:graph:schema');
@@ -10,7 +11,10 @@ const plugins = require('../services/plugins');
const resolvers = require('./resolvers');
const typeDefs = require('./typeDefs');
const schema = makeExecutableSchema({ typeDefs, resolvers });
const schema = makeExecutableSchema({ typeDefs });
// Add the resolvers to the schema
addResolveFunctionsToSchema(schema, resolvers);
// Plugin to the schema level resolvers to provide an before/after hook.
decorateWithHooks(schema, plugins.get('server', 'hooks'));
+1 -5
View File
@@ -1,10 +1,6 @@
// TODO: Adjust `RootQuery.asset(id: ID, url: String)` to instead be
// `RootQuery.asset(id: ID, url: String!)` because we'll always need the url, if
// this change is done now everything will likely break on the front end.
const fs = require('fs');
const path = require('path');
const { mergeStrings } = require('gql-merge');
const { mergeStrings } = require('@coralproject/gql-merge');
const debug = require('debug')('talk:graph:typeDefs');
const plugins = require('../services/plugins');
+1
View File
@@ -206,6 +206,7 @@ en:
error:
COMMENT_PARENT_NOT_VISIBLE: "The comment that you're replying to has been removed or doesn't exist."
EMAIL_VERIFICATION_TOKEN_INVALID: "Email verification token is invalid."
EMAIL_ALREADY_VERIFIED: "Email address already verified."
PASSWORD_RESET_TOKEN_INVALID: "Your password reset link is invalid."
COMMENT_TOO_SHORT: "Comments should be more than one character, please revise your comment and try again."
NOT_AUTHORIZED: "You are not authorized to perform this action."
+32 -16
View File
@@ -13,6 +13,25 @@ const {
const { RECAPTCHA_PUBLIC, WEBSOCKET_LIVE_URI } = require('../config');
// Grab TALK_CLIENT_* environment variables.
const TALK_CLIENT = /^TALK_CLIENT_/i;
// TALK_CLIENT_ENV is all the environment keys that are loaded at runtime.
const TALK_CLIENT_ENV = Object.keys(process.env)
.filter(key => TALK_CLIENT.test(key))
.reduce(
(env, key) => {
env[key] = process.env[key];
return env;
},
{
TALK_RECAPTCHA_PUBLIC: RECAPTCHA_PUBLIC,
LIVE_URI: WEBSOCKET_LIVE_URI,
STATIC_URL,
STATIC_ORIGIN,
}
);
// TEMPLATE_LOCALS stores the static data that is provided as a `text/json` on
// to the client from the template.
const TEMPLATE_LOCALS = {
@@ -20,12 +39,8 @@ const TEMPLATE_LOCALS = {
BASE_PATH,
MOUNT_PATH,
STATIC_URL,
data: {
TALK_RECAPTCHA_PUBLIC: RECAPTCHA_PUBLIC,
LIVE_URI: WEBSOCKET_LIVE_URI,
STATIC_URL,
STATIC_ORIGIN,
},
TALK_CLIENT_ENV,
data: TALK_CLIENT_ENV,
};
// attachStaticLocals will attach the locals to the response only.
@@ -51,28 +66,29 @@ function getManifest() {
}
/**
* resolve is a function that can be used in templates to resolve an asset from
* the manifest. In production, the manifest is cached.
* resolveFactory is a function that can be used in templates to resolve an
* asset from the manifest. In production, the manifest is cached.
*/
const resolve = (() => {
const createResolveFactory = (() => {
if (process.env.NODE_ENV === 'production') {
// In production, we should attempt to load the manifest early.
const manifest = getManifest();
return key => `${STATIC_URL}static/${manifest[key]}`;
return () => key => `${STATIC_URL}static/${manifest[key]}`;
}
// In dev mode, we are more forgiving and we always load the
// newest version of the manifest.
return key => {
return () => {
let manifest = {};
try {
const manifest = getManifest();
return `${STATIC_URL}static/${manifest[key]}`;
manifest = getManifest();
} catch (err) {
console.warn(err);
return '';
}
return key =>
key in manifest ? `${STATIC_URL}static/${manifest[key]}` : '';
};
})();
@@ -90,7 +106,7 @@ module.exports = async (req, res, next) => {
// Resolve will help resolving paths to static files
// using the manifest.
res.locals.resolve = resolve;
res.locals.resolve = createResolveFactory();
// Forward the request.
next();
+8 -7
View File
@@ -1,12 +1,12 @@
{
"name": "talk",
"version": "4.2.2",
"version": "4.3.0",
"description": "A better commenting experience from Mozilla, The New York Times, and the Washington Post. https://coralproject.net",
"main": "app.js",
"private": true,
"scripts": {
"generate-introspection": "WEBPACK=TRUE NODE_ENV=test ./scripts/generateIntrospectionResult.js",
"clean": "rm -rf dist client/coral-framework/graphql/introspection.json",
"clean": "rm -rf dist client/coral-framework/graphql/introspection.json docs/source/_data/introspection.json",
"watch": "npm-run-all clean generate-introspection --parallel watch:*",
"watch:client": "NODE_ENV=development webpack --progress --watch",
"watch:server": "nodemon --config .nodemon.json",
@@ -54,6 +54,7 @@
},
"homepage": "https://github.com/coralproject/talk#readme",
"dependencies": {
"@coralproject/gql-merge": "^0.1.0",
"@coralproject/graphql-anywhere-optimized": "^0.1.0",
"accepts": "^1.3.4",
"apollo-client": "^1.9.1",
@@ -103,10 +104,10 @@
"exports-loader": "^0.6.4",
"express": "4.16.0",
"express-static-gzip": "^0.3.1",
"extract-text-webpack-plugin": "^3.0.2",
"file-loader": "^0.11.2",
"form-data": "^2.3.1",
"fs-extra": "^4.0.1",
"gql-merge": "^0.0.4",
"graphql": "^0.10.1",
"graphql-ast-tools": "0.2.3",
"graphql-docs": "0.2.0",
@@ -125,6 +126,7 @@
"inquirer": "^3.2.2",
"inquirer-autocomplete-prompt": "^0.12.1",
"ioredis": "3.1.4",
"ip": "^1.1.5",
"joi": "^13.0.0",
"jsonwebtoken": "^8.0.0",
"jwt-decode": "^2.2.0",
@@ -142,6 +144,7 @@
"morgan": "^1.9.0",
"ms": "^2.0.0",
"murmurhash-js": "^1.0.0",
"name-all-modules-plugin": "^1.0.1",
"node-emoji": "^1.8.1",
"node-fetch": "^1.7.2",
"nodemailer": "^2.6.4",
@@ -162,6 +165,7 @@
"react-broadcast": "^0.6.2",
"react-dom": "^15.4.2",
"react-input-autosize": "^1.1.4",
"react-loadable": "^5.3.1",
"react-mdl": "^1.11.0",
"react-mdl-selectfield": "^0.2.0",
"react-paginate": "^5.0.0",
@@ -194,6 +198,7 @@
"url-search-params": "^0.9.0",
"uuid": "^3.1.0",
"webpack": "^3.10.0",
"webpack-manifest-plugin": "^2.0.0-rc.2",
"webpack-sources": "^1.0.1",
"yaml-loader": "^0.4.0",
"yamljs": "^0.2.10"
@@ -213,22 +218,18 @@
"enzyme-adapter-react-15": "^1.0.0",
"eslint": "^4.5.0",
"eslint-plugin-mocha": "^4.11.0",
"extract-text-webpack-plugin": "^3.0.2",
"husky": "^0.14.3",
"identity-obj-proxy": "^3.0.0",
"ip": "^1.1.5",
"jest": "^21.2.1",
"jest-junit": "^3.6.0",
"lint-staged": "^7.0.0",
"mocha": "^3.1.2",
"mocha-junit-reporter": "^1.12.1",
"name-all-modules-plugin": "^1.0.1",
"nightwatch": "^0.9.16",
"nodemon": "^1.11.0",
"selenium-standalone": "^6.11.0",
"sinon": "^3.2.1",
"sinon-chai": "^2.13.0",
"webpack-manifest-plugin": "^2.0.0-rc.2",
"yaml-lint": "^1.0.0"
},
"engines": {
+2
View File
@@ -1,6 +1,8 @@
export {
addCommentClassName,
removeCommentClassName,
// @Deprecated
addCommentBoxTag as addTag,
// @Deprecated
removeCommentBoxTag as removeTag,
} from 'coral-embed-stream/src/actions/stream';
+1
View File
@@ -0,0 +1 @@
export { withSlotElements } from 'coral-framework/hocs';
+16 -1
View File
@@ -1,3 +1,18 @@
export const commentBoxTagsSelector = state => state.stream.commentBoxTags;
import once from 'lodash/once';
// @Deprecated
const showOldTagsWarningOnce = once(() => {
if (process.env.NODE_ENV !== 'production') {
console.warn(
'`commentBoxTagsSelector` is deprecated. Please switch to `input.tags` instead'
);
}
});
export const commentBoxTagsSelector = state => {
showOldTagsWarningOnce();
return state.stream.commentBoxTags;
};
export const commentClassNamesSelector = state =>
state.stream.commentClassNames;
@@ -20,6 +20,9 @@ export {
export {
default as CommentContent,
} from 'coral-framework/components/CommentContent';
export {
default as AdminCommentContent,
} from 'coral-framework/components/AdminCommentContent';
export {
default as ConfigureCard,
} from 'coral-framework/components/ConfigureCard';
+2 -1
View File
@@ -1 +1,2 @@
export const pluginConfigSelector = state => state.config.pluginConfig;
export const pluginsConfigSelector = state => state.config.plugins_config;
export const staticConfigSelector = state => state.config.static;
-3
View File
@@ -2,19 +2,16 @@
"server": [
"talk-plugin-auth",
"talk-plugin-featured-comments",
"talk-plugin-offtopic",
"talk-plugin-respect"
],
"client": [
"talk-plugin-auth",
"talk-plugin-author-menu",
"talk-plugin-comment-content",
"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",
@@ -15,8 +15,11 @@ export default class CheckSpamHook extends React.Component {
// If we haven't check the spam yet, make sure to include `checkSpam=true` in the mutation.
// Otherwise post comment without checking the spam.
if (!this.checked) {
input.checkSpam = true;
this.checked = true;
return {
...input,
checkSpam: true,
};
}
});
@@ -4,8 +4,6 @@ import { t } from 'plugin-api/beta/client/services';
import styles from './TermsAndConditionsField.css';
import cn from 'classnames';
const pluginName = 'talk-plugin-auth-checkbox';
const TermsLink = () => (
<a
className={styles.link}
@@ -31,16 +29,15 @@ class TermsAndConditionsField extends React.Component {
id = 'terms-and-conditions';
componentWillMount() {
this.props.indicateBlockerOn(pluginName);
this.props.indicateBlocker();
}
onChange = ({ target: { checked } }) => {
this.setState({ checked });
if (checked) {
this.setState(() => ({ checked }));
this.props.indicateBlockerOff(pluginName);
this.props.indicateBlockerResolved();
} else {
this.setState(() => ({ checked }));
this.props.indicateBlockerOn(pluginName);
this.props.indicateBlocker();
}
};
@@ -29,6 +29,15 @@ class SignUp extends React.Component {
this.props.onSubmit();
};
childFactory = el => {
const key = el.key;
const props = {
indicateBlocker: () => this.props.indicateBlocker(key),
indicateBlockerResolved: () => this.props.indicateBlockerResolved(key),
};
return React.cloneElement(el, props);
};
render() {
const {
username,
@@ -43,17 +52,9 @@ class SignUp extends React.Component {
errorMessage,
requireEmailConfirmation,
success,
indicateBlockerOn,
indicateBlockerOff,
hasBlockers,
blocked,
} = this.props;
const slotPassthrough = {
indicateBlockerOn,
indicateBlockerOff,
hasBlockers,
};
return (
<div>
<div className={styles.header}>
@@ -115,7 +116,7 @@ class SignUp extends React.Component {
/>
<Slot
fill="talkPluginAuth-formField"
passthrough={slotPassthrough}
childFactory={this.childFactory}
/>
<div className={styles.action}>
<Button
@@ -124,7 +125,7 @@ class SignUp extends React.Component {
id="coralSignUpButton"
className={styles.button}
full
disabled={hasBlockers.length}
disabled={blocked}
>
{t('talk-plugin-auth.login.sign_up')}
</Button>
@@ -176,9 +177,9 @@ SignUp.propTypes = {
errorMessage: PropTypes.string,
requireEmailConfirmation: PropTypes.bool.isRequired,
success: PropTypes.bool.isRequired,
hasBlockers: PropTypes.array.isRequired,
indicateBlockerOn: PropTypes.func.isRequired,
indicateBlockerOff: PropTypes.func.isRequired,
blocked: PropTypes.bool.isRequired,
indicateBlocker: PropTypes.func.isRequired,
indicateBlockerResolved: PropTypes.func.isRequired,
};
export default SignUp;
@@ -16,17 +16,17 @@ class SignUpContainer extends Component {
emailError: null,
passwordError: null,
passwordRepeatError: null,
hasBlockers: [],
blockers: [],
};
indicateBlockerOn = plugin =>
indicateBlocker = key =>
this.setState(state => ({
hasBlockers: state.hasBlockers.concat(plugin),
blockers: state.blockers.concat(key),
}));
indicateBlockerOff = plugin =>
indicateBlockerResolved = key =>
this.setState(state => ({
hasBlockers: state.hasBlockers.filter(i => i !== plugin),
blockers: state.blockers.filter(i => i !== key),
}));
validate = data => {
@@ -59,7 +59,7 @@ class SignUpContainer extends Component {
passwordRepeat: this.state.passwordRepeat,
};
if (this.validate(data) && !this.state.hasBlockers.length) {
if (this.validate(data) && !this.state.blockers.length) {
this.props.signUp(data);
}
};
@@ -87,9 +87,9 @@ class SignUpContainer extends Component {
render() {
return (
<SignUp
indicateBlockerOn={this.indicateBlockerOn}
indicateBlockerOff={this.indicateBlockerOff}
hasBlockers={this.state.hasBlockers}
indicateBlocker={this.indicateBlocker}
indicateBlockerResolved={this.indicateBlockerResolved}
blocked={!!this.state.blockers.length}
onSubmit={this.handleSubmit}
onUsernameChange={this.setUsername}
onEmailChange={this.props.setEmail}
@@ -0,0 +1,23 @@
{
"env": {
"browser": true,
"es6": true,
"mocha": true
},
"parserOptions": {
"sourceType": "module",
"ecmaFeatures": {
"experimentalObjectRestSpread": true,
"jsx": true
}
},
"parser": "babel-eslint",
"plugins": [
"react"
],
"rules": {
"react/jsx-uses-react": "error",
"react/jsx-uses-vars": "error",
"no-console": ["warn", { "allow": ["warn", "error"] }]
}
}
@@ -0,0 +1,39 @@
.container {
display: inline-block;
}
.button {
color: #2a2a2a;
margin: 5px 10px 5px 0px;
background: none;
padding: 0px;
border: none;
font-size: inherit;
vertical-align: middle;
&:hover {
color: #767676;
cursor: pointer;
}
&.downvoted {
color: #cc0000;
&:hover {
color: #ff3232;
cursor: pointer;
}
}
}
.icon {
font-size: 12px;
padding: 0 3px;
}
@media (max-width: 425px) {
.label {
display: none;
}
}
@@ -0,0 +1,56 @@
import React from 'react';
import Icon from './Icon';
import styles from './DownvoteButton.css';
import { withReaction } from 'plugin-api/beta/client/hocs';
import cn from 'classnames';
const plugin = 'talk-plugin-downvote';
class DownvoteButton extends React.Component {
handleClick = () => {
const {
postReaction,
deleteReaction,
showSignInDialog,
alreadyReacted,
user,
} = this.props;
// If the current user does not exist, trigger sign in dialog.
if (!user) {
showSignInDialog();
return;
}
if (alreadyReacted) {
deleteReaction();
} else {
postReaction();
}
};
render() {
const { count, alreadyReacted } = this.props;
return (
<div className={cn(styles.container, `${plugin}-container`)}>
<button
className={cn(
styles.button,
{
[`${
styles.downvoted
} talk-plugin-downvote-downvoted`]: alreadyReacted,
},
`${plugin}-button`
)}
onClick={this.handleClick}
>
<Icon className={cn(styles.icon, `${plugin}-icon`)} />
<span className={cn(`${plugin}-count`)}>{count > 0 && count}</span>
</button>
</div>
);
}
}
export default withReaction('downvote')(DownvoteButton);
@@ -0,0 +1,10 @@
import React from 'react';
import cn from 'classnames';
// @TODO change icon when we deprecate FA
export default ({ className }) => (
<i
className={cn('fa', 'fa-arrow-circle-down', className)}
aria-hidden="true"
/>
);
@@ -0,0 +1,7 @@
import DownvoteButton from './components/DownvoteButton';
export default {
slots: {
commentReactions: [DownvoteButton],
},
};
+2
View File
@@ -0,0 +1,2 @@
const { getReactionConfig } = require('../../plugin-api/beta/server');
module.exports = getReactionConfig('downvote');
@@ -0,0 +1,9 @@
{
"name": "@coralproject/talk-plugin-downvote",
"pluginName": "talk-plugin-downvote",
"version": "0.0.1",
"description": "Downvote comments",
"main": "index.js",
"author": "The Coral Project Team <coral@mozillafoundation.org>",
"license": "Apache-2.0"
}
+2 -2
View File
@@ -11,7 +11,7 @@ plugin:
- Client
---
Enables sign-in via Facebook via the server side passport middleware.
Enables sign-in via Google+ via the server side passport middleware.
You will need to enable the Google+ API in the dashboard and create credentials
for a new OAuth client ID web application. The authorized JavaScript origin
@@ -26,4 +26,4 @@ Configuration:
the [Google API Console](https://console.developers.google.com/apis/).
- `TALK_GOOGLE_CLIENT_SECRET` (**required**) - The Google OAuth2 client ID for
your Google login web app. You can learn more about getting a Google Client
ID at the [Google API Console](https://console.developers.google.com/apis/).
ID at the [Google API Console](https://console.developers.google.com/apis/).

Some files were not shown because too many files have changed in this diff Show More