mirror of
https://github.com/wassname/talk.git
synced 2026-07-05 19:06:55 +08:00
Merge branch 'update-default-plugins' of git+ssh://github.com/coralproject/talk into update-default-plugins
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"presets": [
|
||||
["es2015", {modules: false}]
|
||||
["es2015", {"modules": false}]
|
||||
],
|
||||
"plugins": [
|
||||
"transform-class-properties",
|
||||
|
||||
@@ -30,6 +30,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,14 +53,17 @@ 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
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
<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>
|
||||
<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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
+6
-4
@@ -146,10 +146,12 @@ sidebar:
|
||||
url: /customizing-plugins-coral-ui/
|
||||
- title: API
|
||||
children:
|
||||
- title: Server Plugins
|
||||
url: /reference/server/
|
||||
- title: GraphQL
|
||||
url: /reference/graphql/
|
||||
- title: GraphQL Overview
|
||||
url: /api/overview/
|
||||
- title: GraphQL Reference
|
||||
url: /api/graphql/
|
||||
- title: Server Plugin API
|
||||
url: /api/server/
|
||||
- title: Migrating
|
||||
children:
|
||||
- title: Migrating to v4.0.0
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
/ /talk/
|
||||
/ /talk/
|
||||
/talk/reference/server/ /talk/api/server/
|
||||
/talk/reference/graphql/ /talk/api/graphql/
|
||||
@@ -1,9 +1,15 @@
|
||||
---
|
||||
title: GraphQL API
|
||||
permalink: /reference/graphql/
|
||||
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 ../../client/coral-framework/graphql/introspection.json %}
|
||||
@@ -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
|
||||
---
|
||||
+2
-2
@@ -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) {
|
||||
|
||||
+5
-1
@@ -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
@@ -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');
|
||||
|
||||
|
||||
+5
-5
@@ -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",
|
||||
@@ -194,6 +197,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 +217,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": {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
export {
|
||||
addCommentClassName,
|
||||
removeCommentClassName,
|
||||
// @Deprecated
|
||||
addCommentBoxTag as addTag,
|
||||
// @Deprecated
|
||||
removeCommentBoxTag as removeTag,
|
||||
} from 'coral-embed-stream/src/actions/stream';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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],
|
||||
},
|
||||
};
|
||||
@@ -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"
|
||||
}
|
||||
@@ -1,47 +1,27 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import styles from './OffTopicCheckbox.css';
|
||||
|
||||
import { t } from 'plugin-api/beta/client/services';
|
||||
|
||||
export default class OffTopicCheckbox extends React.Component {
|
||||
label = 'OFF_TOPIC';
|
||||
|
||||
componentDidMount() {
|
||||
this.clearTagsHook = this.props.registerHook('postSubmit', () => {
|
||||
const idx = this.props.tags.indexOf(this.label);
|
||||
this.props.removeTag(idx);
|
||||
});
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.props.unregisterHook(this.clearTagsHook);
|
||||
}
|
||||
|
||||
handleChange = e => {
|
||||
const { addTag, removeTag } = this.props;
|
||||
if (e.target.checked) {
|
||||
addTag(this.label);
|
||||
} else {
|
||||
const idx = this.props.tags.indexOf(this.label);
|
||||
removeTag(idx);
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const checked = this.props.tags.indexOf(this.label) >= 0;
|
||||
return (
|
||||
<div className={styles.offTopic}>
|
||||
{!this.props.isReply ? (
|
||||
<label className={styles.offTopicLabel}>
|
||||
<input
|
||||
type="checkbox"
|
||||
onChange={this.handleChange}
|
||||
checked={checked}
|
||||
/>
|
||||
{t('off_topic')}
|
||||
</label>
|
||||
) : null}
|
||||
<label className={styles.offTopicLabel}>
|
||||
<input
|
||||
type="checkbox"
|
||||
onChange={this.props.onChange}
|
||||
checked={this.props.checked}
|
||||
/>
|
||||
{t('off_topic')}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
OffTopicCheckbox.propTypes = {
|
||||
onChange: PropTypes.func.isRequired,
|
||||
checked: PropTypes.bool.isRequired,
|
||||
};
|
||||
|
||||
@@ -1,14 +1,34 @@
|
||||
import { bindActionCreators } from 'redux';
|
||||
import { addTag, removeTag } from 'plugin-api/alpha/client/actions';
|
||||
import { commentBoxTagsSelector } from 'plugin-api/alpha/client/selectors';
|
||||
import { connect } from 'plugin-api/beta/client/hocs';
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import OffTopicCheckbox from '../components/OffTopicCheckbox';
|
||||
import { excludeIf } from 'plugin-api/beta/client/hocs';
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
tags: commentBoxTagsSelector(state),
|
||||
});
|
||||
const OFF_TOPIC_TAG = 'OFF_TOPIC';
|
||||
class OffTopicCheckboxContainer extends React.Component {
|
||||
handleChange = e => {
|
||||
const { input, onInputChange } = this.props;
|
||||
if (e.target.checked && !input.tags.includes(OFF_TOPIC_TAG)) {
|
||||
onInputChange({ tags: [...input.tags, OFF_TOPIC_TAG] });
|
||||
} else {
|
||||
const idx = input.tags.indexOf(OFF_TOPIC_TAG);
|
||||
if (idx !== -1) {
|
||||
onInputChange({
|
||||
tags: [...input.tags.slice(0, idx), ...input.tags.slice(0, idx)],
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const mapDispatchToProps = dispatch =>
|
||||
bindActionCreators({ addTag, removeTag }, dispatch);
|
||||
render() {
|
||||
const checked = this.props.input.tags.includes(OFF_TOPIC_TAG);
|
||||
return <OffTopicCheckbox checked={checked} onChange={this.handleChange} />;
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(OffTopicCheckbox);
|
||||
OffTopicCheckboxContainer.propTypes = {
|
||||
input: PropTypes.object.isRequired,
|
||||
onInputChange: PropTypes.func.isRequired,
|
||||
isReply: PropTypes.bool,
|
||||
};
|
||||
|
||||
export default excludeIf(props => props.isReply)(OffTopicCheckboxContainer);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import cn from 'classnames';
|
||||
|
||||
// @TODO change icon when we deprecate FA
|
||||
export default ({ className }) => (
|
||||
<i className={cn('fa', 'fa-handshake-o', className)} aria-hidden="true" />
|
||||
);
|
||||
|
||||
@@ -6,6 +6,7 @@ plugin:
|
||||
name: talk-plugin-rich-text
|
||||
provides:
|
||||
- Client
|
||||
- Server
|
||||
---
|
||||
|
||||
Enables secure rich text support server-side.
|
||||
@@ -13,11 +14,11 @@ Enables secure rich text support server-side.
|
||||
## Installation
|
||||
|
||||
Add `"talk-plugin-rich-text"` to the `plugins.json` in your Talk installation.
|
||||
Remember to add this in the `server` property since this plugin only covers the
|
||||
server side. To add frontend support consider using
|
||||
[talk-plugin-rich-text-pell](/talk/plugin/talk-plugin-rich-text-pell).
|
||||
This plugin provides a server and a client side implementation.
|
||||
|
||||
## How does this work?
|
||||
## Server implementation
|
||||
|
||||
### How does this work?
|
||||
|
||||
This plugin uses the `comment.metadata` field to store the `richTextBody`. By
|
||||
adding `richTextBody` to the schema we can later on resolve it as part of the
|
||||
@@ -27,23 +28,49 @@ the capabilities of our plugin framework. We encourage you to see the files and
|
||||
check how easy is to build plugins! If you have any feedback, please let us
|
||||
know.
|
||||
|
||||
## Configuration
|
||||
### Configuration
|
||||
|
||||
There is a `config.js` in the root folder. This file contains the recommended
|
||||
settings.
|
||||
|
||||
### `highlightLinks`
|
||||
#### `highlightLinks`
|
||||
|
||||
A `boolean` to highlight links. Set it to `false` to turn it off.
|
||||
|
||||
### `linkify`
|
||||
#### `linkify`
|
||||
|
||||
Settings for highlighting links. These will only apply if `higlightLinks` is set to `true`.
|
||||
|
||||
### `dompurify`
|
||||
#### `dompurify`
|
||||
|
||||
Rules to sanitize html input. We use [DOMPurify] (https://github.com/cure53/DOMPurify) to prevent web attacks and XSS. Here is the complete list of [settings] (https://github.com/cure53/DOMPurify)
|
||||
|
||||
## `jsdom`
|
||||
#### `jsdom`
|
||||
|
||||
In order to run html in the server we need [jsdom](https://github.com/jsdom/jsdom). Usually you wouldn’t need to modify this settings.
|
||||
|
||||
## Client implementation
|
||||
|
||||
### How does this work?
|
||||
|
||||
This plugin contains 2 important components:
|
||||
|
||||
- The Editor (`./components/Editor.js`)
|
||||
- The Comment Content Renderer (`./components/CommentContent.js`)
|
||||
|
||||
The editor component utilizes the [contentEditable](https://developer.mozilla.org/en-US/docs/Web/Guide/HTML/Editable_content) and execCommand API.
|
||||
|
||||
If you check our `index.js` you will notice that we inject this editor in the
|
||||
`commentBox` slot. We do this to replace the core comment box with this one.
|
||||
|
||||
Now, in order to render the new styled comments we need a comment renderer. For
|
||||
this task we will have to replace our core comment renderer by using the
|
||||
`commentContent` slot.
|
||||
|
||||
If you are not familiar with GraphQL `client/index.js` will look complicated,
|
||||
but fear not! With those functions we specify what to expect from the server
|
||||
schema, how to perform optimistic updates and how keep the client store updated
|
||||
with the latest changes.
|
||||
|
||||
We encourage you to see the files and check how easy is to build plugins! If you
|
||||
have any feedback, please let us know.
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "@coralproject/eslint-config-talk/client"
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
.button > i {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.button {
|
||||
background-color: transparent;
|
||||
padding: 3px;
|
||||
border: none;
|
||||
color: #4e4e4e;
|
||||
margin-right: 3px;
|
||||
}
|
||||
|
||||
.button:hover{
|
||||
cursor: pointer;
|
||||
border-radius: 3px;
|
||||
background-color: #eae8e8;
|
||||
}
|
||||
.icon {
|
||||
font-size: 20px;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import styles from './Button.css';
|
||||
import { Icon, BareButton } from 'plugin-api/beta/client/components/ui';
|
||||
import cn from 'classnames';
|
||||
|
||||
class Button extends React.Component {
|
||||
render() {
|
||||
const { className, icon, title, onClick } = this.props;
|
||||
return (
|
||||
<BareButton
|
||||
className={cn(className, styles.button)}
|
||||
title={title}
|
||||
onClick={onClick}
|
||||
>
|
||||
<Icon className={styles.icon} name={icon} />
|
||||
</BareButton>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Button.propTypes = {
|
||||
icon: PropTypes.string.isRequired,
|
||||
className: PropTypes.string,
|
||||
title: PropTypes.string,
|
||||
onClick: PropTypes.func,
|
||||
};
|
||||
|
||||
export default Button;
|
||||
@@ -0,0 +1,15 @@
|
||||
.content {
|
||||
blockquote {
|
||||
background-color: #F6F6F6;
|
||||
padding: 10px;
|
||||
margin: 20px 0px 20px 10px;
|
||||
font-style: italic;
|
||||
border-radius: 2px;
|
||||
&::after {
|
||||
content: none;
|
||||
}
|
||||
&::before {
|
||||
content: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { PLUGIN_NAME } from '../constants';
|
||||
import cn from 'classnames';
|
||||
import styles from './CommentContent.css';
|
||||
|
||||
class CommentContent extends React.Component {
|
||||
render() {
|
||||
const { comment } = this.props;
|
||||
const className = cn(`${PLUGIN_NAME}-text`, styles.content);
|
||||
return comment.richTextBody ? (
|
||||
<div
|
||||
className={className}
|
||||
dangerouslySetInnerHTML={{ __html: comment.richTextBody }}
|
||||
/>
|
||||
) : (
|
||||
<div className={className}>{comment.body}</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
CommentContent.propTypes = {
|
||||
comment: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
export default CommentContent;
|
||||
@@ -0,0 +1,18 @@
|
||||
.contentEditable {
|
||||
composes: content from "./CommentContent.css";
|
||||
background: #fff;
|
||||
border: solid 1px #bbb;
|
||||
min-height: 120px;
|
||||
box-sizing: border-box;
|
||||
outline: 0;
|
||||
overflow-y: auto;
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
font-style: unset;
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
position: absolute;
|
||||
margin: 12px 0 0 12px;
|
||||
color: #bbb;
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import styles from './Editor.css';
|
||||
import cn from 'classnames';
|
||||
import { PLUGIN_NAME } from '../constants';
|
||||
import { htmlNormalizer } from '../utils';
|
||||
import ContentEditable from 'react-contenteditable';
|
||||
import Toolbar from './Toolbar';
|
||||
import Button from './Button';
|
||||
import bowser from 'bowser';
|
||||
|
||||
class Editor extends React.Component {
|
||||
ref = null;
|
||||
handleRef = ref => (this.ref = ref);
|
||||
|
||||
handleChange = evt => {
|
||||
this.props.onInputChange({
|
||||
body: this.ref.htmlEl.innerText,
|
||||
richTextBody: evt.target.value,
|
||||
});
|
||||
};
|
||||
|
||||
getHTML(props = this.props) {
|
||||
if (props.input.richTextBody) {
|
||||
return props.input.richTextBody;
|
||||
}
|
||||
return (
|
||||
(props.isEdit && (props.comment.richTextBody || props.comment.body)) || ''
|
||||
);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
if (this.props.registerHook) {
|
||||
this.normalizeHook = this.props.registerHook('preSubmit', input => {
|
||||
if (input.richTextBody) {
|
||||
return {
|
||||
...input,
|
||||
richTextBody: htmlNormalizer(input.richTextBody),
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.props.unregisterHook(this.normalizeHook);
|
||||
}
|
||||
|
||||
getCurrentTagName() {
|
||||
const sel = window.getSelection();
|
||||
const range = sel.getRangeAt(0);
|
||||
if (range.startContainer.nodeName !== '#text') {
|
||||
return range.startContainer.nodeName;
|
||||
}
|
||||
return range.startContainer.parentNode.tagName;
|
||||
}
|
||||
|
||||
formatBold = () => {
|
||||
document.execCommand('bold');
|
||||
this.ref.htmlEl.focus();
|
||||
};
|
||||
|
||||
formatItalic = () => {
|
||||
document.execCommand('italic');
|
||||
this.ref.htmlEl.focus();
|
||||
};
|
||||
|
||||
formatBlockquote = () => {
|
||||
const currentTag = this.getCurrentTagName();
|
||||
if (currentTag === 'BLOCKQUOTE') {
|
||||
document.execCommand('outdent');
|
||||
} else {
|
||||
if (bowser.msie) {
|
||||
document.execCommand('indent');
|
||||
} else {
|
||||
document.execCommand('formatBlock', false, 'blockquote');
|
||||
}
|
||||
}
|
||||
this.ref.htmlEl.focus();
|
||||
};
|
||||
|
||||
outdentOnEnter = e => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
setTimeout(() => {
|
||||
document.execCommand('outdent');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const inputId = `${this.props.id}-rte`;
|
||||
return (
|
||||
<div className={cn(styles.root, `${PLUGIN_NAME}-container`)}>
|
||||
<label
|
||||
htmlFor={inputId}
|
||||
className="screen-reader-text"
|
||||
aria-hidden={true}
|
||||
>
|
||||
{this.props.label}
|
||||
</label>
|
||||
<Toolbar>
|
||||
<Button icon="format_bold" title="bold" onClick={this.formatBold} />
|
||||
<Button
|
||||
icon="format_italic"
|
||||
title="italic"
|
||||
onClick={this.formatItalic}
|
||||
/>
|
||||
<Button
|
||||
icon="format_quote"
|
||||
title="quote"
|
||||
onClick={this.formatBlockquote}
|
||||
/>
|
||||
</Toolbar>
|
||||
{!this.props.input.body && (
|
||||
<div className={styles.placeholder}>{this.props.placeholder}</div>
|
||||
)}
|
||||
<ContentEditable
|
||||
id={inputId}
|
||||
onKeyPress={this.outdentOnEnter}
|
||||
className={styles.contentEditable}
|
||||
ref={this.handleRef}
|
||||
html={this.getHTML()}
|
||||
disabled={false}
|
||||
onChange={this.handleChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Editor.propTypes = {
|
||||
input: PropTypes.object,
|
||||
placeholder: PropTypes.string,
|
||||
onInputChange: PropTypes.func,
|
||||
disabled: PropTypes.bool,
|
||||
comment: PropTypes.object,
|
||||
classNames: PropTypes.object,
|
||||
registerHook: PropTypes.func,
|
||||
unregisterHook: PropTypes.func,
|
||||
isReply: PropTypes.bool,
|
||||
isEdit: PropTypes.bool,
|
||||
id: PropTypes.string,
|
||||
label: PropTypes.string,
|
||||
placeholder: PropTypes.string,
|
||||
};
|
||||
|
||||
export default Editor;
|
||||
@@ -0,0 +1,7 @@
|
||||
.toolbar {
|
||||
user-select: none;
|
||||
padding: 5px 10px;
|
||||
border-top: 1px solid #bbb;
|
||||
border-left: 1px solid #bbb;
|
||||
border-right: 1px solid #bbb;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import styles from './Toolbar.css';
|
||||
import cn from 'classnames';
|
||||
|
||||
class Toolbar extends React.Component {
|
||||
render() {
|
||||
const { className, ...rest } = this.props;
|
||||
return <div className={cn(className, styles.toolbar)} {...rest} />;
|
||||
}
|
||||
}
|
||||
|
||||
Toolbar.propTypes = {
|
||||
className: PropTypes.string,
|
||||
};
|
||||
|
||||
export default Toolbar;
|
||||
@@ -0,0 +1 @@
|
||||
export const PLUGIN_NAME = 'talk-plugin-rich-text';
|
||||
@@ -0,0 +1,12 @@
|
||||
import { gql } from 'react-apollo';
|
||||
import { withFragments } from 'plugin-api/beta/client/hocs';
|
||||
import CommentContent from '../components/CommentContent';
|
||||
|
||||
export default withFragments({
|
||||
comment: gql`
|
||||
fragment TalkPluginRichText_CommentContent_comment on Comment {
|
||||
body
|
||||
richTextBody
|
||||
}
|
||||
`,
|
||||
})(CommentContent);
|
||||
@@ -0,0 +1,12 @@
|
||||
import { gql } from 'react-apollo';
|
||||
import { withFragments } from 'plugin-api/beta/client/hocs';
|
||||
import Editor from '../components/Editor';
|
||||
|
||||
export default withFragments({
|
||||
comment: gql`
|
||||
fragment TalkPluginRichText_Editor_comment on Comment {
|
||||
body
|
||||
richTextBody
|
||||
}
|
||||
`,
|
||||
})(Editor);
|
||||
@@ -0,0 +1,70 @@
|
||||
import Editor from './containers/Editor';
|
||||
import CommentContent from './containers/CommentContent';
|
||||
import { gql } from 'react-apollo';
|
||||
|
||||
export default {
|
||||
slots: {
|
||||
draftArea: [Editor],
|
||||
commentContent: [CommentContent],
|
||||
adminCommentContent: [CommentContent],
|
||||
userDetailCommentContent: [CommentContent],
|
||||
},
|
||||
fragments: {
|
||||
CreateCommentResponse: gql`
|
||||
fragment TalkRichText_CreateCommentResponse on CreateCommentResponse {
|
||||
comment {
|
||||
richTextBody
|
||||
}
|
||||
}
|
||||
`,
|
||||
EditCommentResponse: gql`
|
||||
fragment TalkRichText_EditCommentResponse on EditCommentResponse {
|
||||
comment {
|
||||
richTextBody
|
||||
}
|
||||
}
|
||||
`,
|
||||
},
|
||||
mutations: {
|
||||
PostComment: ({ variables: { input } }) => {
|
||||
return {
|
||||
optimisticResponse: {
|
||||
createComment: {
|
||||
comment: {
|
||||
richTextBody: input.richTextBody,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
EditComment: ({ variables: { id, edit } }) => {
|
||||
return {
|
||||
optimisticResponse: {
|
||||
editComment: {
|
||||
comment: {
|
||||
richTextBody: edit.richTextBody,
|
||||
},
|
||||
},
|
||||
},
|
||||
update: proxy => {
|
||||
const editCommentFragment = gql`
|
||||
fragment TalkRichText_EditComment on Comment {
|
||||
richTextBody
|
||||
}
|
||||
`;
|
||||
|
||||
const fragmentId = `Comment_${id}`;
|
||||
|
||||
proxy.writeFragment({
|
||||
fragment: editCommentFragment,
|
||||
id: fragmentId,
|
||||
data: {
|
||||
__typename: 'Comment',
|
||||
richTextBody: edit.richTextBody,
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
export function htmlNormalizer(htmlInput) {
|
||||
let str = htmlInput;
|
||||
// We are normalizing the input from contenteditable of each browser, also removing unnecesary html tags
|
||||
// https://developer.mozilla.org/en-US/docs/Web/Guide/HTML/Editable_content#Differences_in_markup_generation
|
||||
|
||||
// Old browsers uses `p` normalize to `div` instead.
|
||||
str = str
|
||||
.replace(/<p>/g, '<div>') // IE and old browsers outputs <p> instead of <div>s
|
||||
.replace(/<\/p>/g, '</div>'); // IE and old browsers outputs <p> instead of <div>s
|
||||
|
||||
// Harmonize all to <b> tag.
|
||||
str = str
|
||||
.replace(/<strong>/g, '<b>') // IE
|
||||
.replace(/<\/strong>/g, '</b>'); // IE
|
||||
|
||||
// Harmonize all to <i> tag.
|
||||
str = str
|
||||
.replace(/<em>/g, '<i>') // IE
|
||||
.replace(/<\/em>/g, '</i>'); // IE
|
||||
return str;
|
||||
}
|
||||
@@ -9,6 +9,7 @@
|
||||
"dependencies": {
|
||||
"dompurify": "^1.0.3",
|
||||
"jsdom": "^11.6.2",
|
||||
"linkifyjs": "^2.1.5"
|
||||
"linkifyjs": "^2.1.5",
|
||||
"react-contenteditable": "^2.0.7"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,10 @@ const config = {
|
||||
|
||||
// TODO: move to admin eventually
|
||||
// Super strict rules to make sure users only submit the tags they are allowed
|
||||
dompurify: { ALLOWED_TAGS: ['b', 'i', 'blockquote', 'br'] },
|
||||
dompurify: {
|
||||
ALLOWED_TAGS: ['b', 'i', 'blockquote', 'br', 'div'],
|
||||
ALLOWED_ATTR: [],
|
||||
},
|
||||
|
||||
// Secure config for jsdom even when DOMPurify creates a document without a browsing context
|
||||
jsdom: {
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "@coralproject/eslint-config-talk/client"
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import translations from './translations.yml';
|
||||
import { createSortOption } from 'talk-plugin-viewing-options/client/api/factories';
|
||||
import { t } from 'plugin-api/beta/client/services';
|
||||
|
||||
const SortOption = createSortOption(
|
||||
() => t('talk-plugin-sort-most-downvoted.label'),
|
||||
{ sortBy: 'DOWNVOTES', sortOrder: 'DESC' }
|
||||
);
|
||||
|
||||
/**
|
||||
* This plugin depends on talk-plugin-viewing-options.
|
||||
*/
|
||||
|
||||
export default {
|
||||
translations,
|
||||
slots: {
|
||||
viewingOptionsSort: [SortOption],
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
en:
|
||||
talk-plugin-sort-most-downvoted:
|
||||
label: Most downvoted first
|
||||
@@ -0,0 +1 @@
|
||||
module.exports = {};
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "@coralproject/talk-plugin-sort-most-downvoted",
|
||||
"pluginName": "talk-plugin-sort-most-downvoted",
|
||||
"version": "0.0.1",
|
||||
"description": "Sort by most downvotes",
|
||||
"main": "index.js",
|
||||
"author": "The Coral Project Team <coral@mozillafoundation.org>",
|
||||
"license": "Apache-2.0"
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "@coralproject/eslint-config-talk/client"
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import translations from './translations.yml';
|
||||
import { createSortOption } from 'talk-plugin-viewing-options/client/api/factories';
|
||||
import { t } from 'plugin-api/beta/client/services';
|
||||
|
||||
const SortOption = createSortOption(
|
||||
() => t('talk-plugin-sort-most-upvoted.label'),
|
||||
{ sortBy: 'UPVOTES', sortOrder: 'DESC' }
|
||||
);
|
||||
|
||||
/**
|
||||
* This plugin depends on talk-plugin-viewing-options.
|
||||
*/
|
||||
|
||||
export default {
|
||||
translations,
|
||||
slots: {
|
||||
viewingOptionsSort: [SortOption],
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
en:
|
||||
talk-plugin-sort-most-upvoted:
|
||||
label: Most upvoted first
|
||||
@@ -0,0 +1 @@
|
||||
module.exports = {};
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "@coralproject/talk-plugin-sort-most-upvoted",
|
||||
"pluginName": "talk-plugin-sort-most-upvoted",
|
||||
"version": "0.0.1",
|
||||
"description": "Sort by most upvotes",
|
||||
"main": "index.js",
|
||||
"author": "The Coral Project Team <coral@mozillafoundation.org>",
|
||||
"license": "Apache-2.0"
|
||||
}
|
||||
@@ -15,8 +15,11 @@ export default class CheckToxicityHook extends React.Component {
|
||||
// If we haven't check the toxicity yet, make sure to include `checkToxicity=true` in the mutation.
|
||||
// Otherwise post comment without checking the toxicity.
|
||||
if (!this.checked) {
|
||||
input.checkToxicity = true;
|
||||
this.checked = true;
|
||||
return {
|
||||
...input,
|
||||
checkToxicity: true,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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,7 @@
|
||||
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-up', className)} aria-hidden="true" />
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
&.upvoted {
|
||||
color: #008000;
|
||||
|
||||
&:hover {
|
||||
color: #66b266;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: 12px;
|
||||
padding: 0 3px;
|
||||
}
|
||||
|
||||
@media (max-width: 425px) {
|
||||
.label {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import React from 'react';
|
||||
import Icon from './Icon';
|
||||
import styles from './UpvoteButton.css';
|
||||
import { withReaction } from 'plugin-api/beta/client/hocs';
|
||||
import cn from 'classnames';
|
||||
|
||||
const plugin = 'talk-plugin-upvote';
|
||||
|
||||
class UpvoteButton 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.upvoted} talk-plugin-upvote-upvoted`]: 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('upvote')(UpvoteButton);
|
||||
@@ -0,0 +1,7 @@
|
||||
import UpvoteButton from './components/UpvoteButton';
|
||||
|
||||
export default {
|
||||
slots: {
|
||||
commentReactions: [UpvoteButton],
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
const { getReactionConfig } = require('../../plugin-api/beta/server');
|
||||
module.exports = getReactionConfig('upvote');
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "@coralproject/talk-plugin-upvote",
|
||||
"pluginName": "talk-plugin-upvote",
|
||||
"version": "0.0.1",
|
||||
"description": "Upvote comments",
|
||||
"main": "index.js",
|
||||
"author": "The Coral Project Team <coral@mozillafoundation.org>",
|
||||
"license": "Apache-2.0"
|
||||
}
|
||||
+5
-4
@@ -13,6 +13,7 @@ const staticMiddleware = require('express-static-gzip');
|
||||
const { DISABLE_STATIC_SERVER } = require('../config');
|
||||
const { passport } = require('../services/passport');
|
||||
const { MOUNT_PATH } = require('../url');
|
||||
const url = require('url');
|
||||
const context = require('../middleware/context');
|
||||
|
||||
const router = express.Router();
|
||||
@@ -31,8 +32,8 @@ if (!DISABLE_STATIC_SERVER) {
|
||||
/**
|
||||
* Redirect old embed calls.
|
||||
*/
|
||||
const oldEmbed = path.resolve(MOUNT_PATH, 'embed.js');
|
||||
const newEmbed = path.resolve(MOUNT_PATH, 'static/embed.js');
|
||||
const oldEmbed = url.resolve(MOUNT_PATH, 'embed.js');
|
||||
const newEmbed = url.resolve(MOUNT_PATH, 'static/embed.js');
|
||||
router.get('/embed.js', (req, res) => {
|
||||
console.warn(
|
||||
`deprecation warning: ${oldEmbed} will be phased out soon, please replace calls from ${oldEmbed} to ${newEmbed}`
|
||||
@@ -132,9 +133,9 @@ if (process.env.NODE_ENV !== 'production') {
|
||||
router.get('/', async (req, res, next) => {
|
||||
try {
|
||||
await SetupService.isAvailable();
|
||||
return res.redirect('/admin/install');
|
||||
return res.redirect(url.resolve(MOUNT_PATH, 'admin/install'));
|
||||
} catch (e) {
|
||||
return res.redirect('/admin');
|
||||
return res.redirect(url.resolve(MOUNT_PATH, 'admin'));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
const path = require('path');
|
||||
const fs = require('fs-extra');
|
||||
const fs = require('fs');
|
||||
const { get, set, template } = require('lodash');
|
||||
|
||||
// load all the templates as strings
|
||||
@@ -9,20 +9,20 @@ const templates = {
|
||||
};
|
||||
|
||||
// Registers a template with the given filename and format.
|
||||
templates.register = async (filename, name, format) => {
|
||||
templates.register = (filename, name, format) => {
|
||||
// Check to see if this template was already registered.
|
||||
if (get(templates.registered, [name, format], null) !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const file = await fs.readFile(filename, 'utf8');
|
||||
const file = fs.readFileSync(filename, 'utf8');
|
||||
const view = template(file);
|
||||
|
||||
set(templates.registered, [name, format], view);
|
||||
};
|
||||
|
||||
// load the templates per request during development
|
||||
templates.render = async (name, format = 'txt', context) => {
|
||||
templates.render = (name, format = 'txt', context) => {
|
||||
// Check to see if the template is a registered template (provided by a plugin
|
||||
// ) and prefer that first.
|
||||
let view = get(templates.registered, [name, format], null);
|
||||
@@ -44,7 +44,7 @@ templates.render = async (name, format = 'txt', context) => {
|
||||
'templates',
|
||||
[name, format, 'ejs'].join('.')
|
||||
);
|
||||
const file = await fs.readFile(filename, 'utf8');
|
||||
const file = fs.readFileSync(filename, 'utf8');
|
||||
view = template(file);
|
||||
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
|
||||
@@ -80,6 +80,19 @@
|
||||
eslint-plugin-react "^7.5.1"
|
||||
prettier "^1.10.2"
|
||||
|
||||
"@coralproject/gql-format@^0.1.0":
|
||||
version "0.1.0"
|
||||
resolved "https://registry.yarnpkg.com/@coralproject/gql-format/-/gql-format-0.1.0.tgz#76a0f9a672076482f665781d0c09e39e14f70491"
|
||||
dependencies:
|
||||
graphql "0.9.2"
|
||||
|
||||
"@coralproject/gql-merge@^0.1.0":
|
||||
version "0.1.2"
|
||||
resolved "https://registry.yarnpkg.com/@coralproject/gql-merge/-/gql-merge-0.1.2.tgz#3e5c79e1da71eb713a4eb3df16f64eaa3de2bad5"
|
||||
dependencies:
|
||||
"@coralproject/gql-format" "^0.1.0"
|
||||
graphql "0.9.2"
|
||||
|
||||
"@coralproject/graphql-anywhere-optimized@^0.1.0":
|
||||
version "0.1.6"
|
||||
resolved "https://registry.yarnpkg.com/@coralproject/graphql-anywhere-optimized/-/graphql-anywhere-optimized-0.1.6.tgz#073b33764c04788b0290788da9ebf0ed21af6437"
|
||||
@@ -1354,7 +1367,7 @@ bluebird@3.5.0:
|
||||
version "3.5.0"
|
||||
resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.5.0.tgz#791420d7f551eea2897453a8a77653f96606d67c"
|
||||
|
||||
bluebird@^3.0.6, bluebird@^3.1.1, bluebird@^3.2.2, bluebird@^3.3.4, bluebird@^3.4.0, bluebird@^3.4.6, bluebird@^3.5.0, bluebird@^3.5.1:
|
||||
bluebird@^3.0.6, bluebird@^3.1.1, bluebird@^3.2.2, bluebird@^3.3.4, bluebird@^3.4.0, bluebird@^3.5.0, bluebird@^3.5.1:
|
||||
version "3.5.1"
|
||||
resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.5.1.tgz#d9551f9de98f1fcda1e683d17ee91a0602ee2eb9"
|
||||
|
||||
@@ -4284,29 +4297,6 @@ got@^6.7.1:
|
||||
unzip-response "^2.0.1"
|
||||
url-parse-lax "^1.0.0"
|
||||
|
||||
gql-format@^0.0.4:
|
||||
version "0.0.4"
|
||||
resolved "https://registry.yarnpkg.com/gql-format/-/gql-format-0.0.4.tgz#8237de7647de37f00aba2d0073abf6087e2da119"
|
||||
dependencies:
|
||||
commander "^2.9.0"
|
||||
graphql "^0.7.2"
|
||||
|
||||
gql-merge@^0.0.4:
|
||||
version "0.0.4"
|
||||
resolved "https://registry.yarnpkg.com/gql-merge/-/gql-merge-0.0.4.tgz#1cb1d4cc8bb8768172cf08a45c5a4fbd0ecedc9f"
|
||||
dependencies:
|
||||
commander "^2.9.0"
|
||||
gql-format "^0.0.4"
|
||||
gql-utils "^0.0.2"
|
||||
graphql "^0.7.2"
|
||||
|
||||
gql-utils@^0.0.2:
|
||||
version "0.0.2"
|
||||
resolved "https://registry.yarnpkg.com/gql-utils/-/gql-utils-0.0.2.tgz#962b3c1b34bf965a45d2564a93d3072921f61e86"
|
||||
dependencies:
|
||||
bluebird "^3.4.6"
|
||||
glob "^7.1.1"
|
||||
|
||||
graceful-fs@^4.1.11, graceful-fs@^4.1.2, graceful-fs@^4.1.3, graceful-fs@^4.1.4, graceful-fs@^4.1.6, graceful-fs@^4.1.9:
|
||||
version "4.1.11"
|
||||
resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.1.11.tgz#0e8bdfe4d1ddb8854d64e04ea7c00e2a026e5658"
|
||||
@@ -4382,18 +4372,18 @@ graphql-tools@^0.10.1:
|
||||
optionalDependencies:
|
||||
"@types/graphql" "^0.8.5"
|
||||
|
||||
graphql@0.9.2:
|
||||
version "0.9.2"
|
||||
resolved "https://registry.yarnpkg.com/graphql/-/graphql-0.9.2.tgz#2cb5c635de13f790a77c5879649cb401b1589386"
|
||||
dependencies:
|
||||
iterall "1.0.3"
|
||||
|
||||
graphql@^0.10.0, graphql@^0.10.1, graphql@^0.10.3:
|
||||
version "0.10.5"
|
||||
resolved "https://registry.yarnpkg.com/graphql/-/graphql-0.10.5.tgz#c9be17ca2bdfdbd134077ffd9bbaa48b8becd298"
|
||||
dependencies:
|
||||
iterall "^1.1.0"
|
||||
|
||||
graphql@^0.7.2:
|
||||
version "0.7.2"
|
||||
resolved "https://registry.yarnpkg.com/graphql/-/graphql-0.7.2.tgz#cc894a32823399b8a0cb012b9e9ecad35cd00f72"
|
||||
dependencies:
|
||||
iterall "1.0.2"
|
||||
|
||||
growl@1.9.2:
|
||||
version "1.9.2"
|
||||
resolved "https://registry.yarnpkg.com/growl/-/growl-1.9.2.tgz#0ea7743715db8d8de2c5ede1775e1b45ac85c02f"
|
||||
@@ -5567,9 +5557,9 @@ istanbul-reports@^1.1.2:
|
||||
dependencies:
|
||||
handlebars "^4.0.3"
|
||||
|
||||
iterall@1.0.2:
|
||||
version "1.0.2"
|
||||
resolved "https://registry.yarnpkg.com/iterall/-/iterall-1.0.2.tgz#41a2e96ce9eda5e61c767ee5dc312373bb046e91"
|
||||
iterall@1.0.3:
|
||||
version "1.0.3"
|
||||
resolved "https://registry.yarnpkg.com/iterall/-/iterall-1.0.3.tgz#e0b31958f835013c323ff0b10943829ac69aa4b7"
|
||||
|
||||
iterall@^1.1.0, iterall@^1.1.1:
|
||||
version "1.1.3"
|
||||
@@ -9190,6 +9180,10 @@ react-broadcast@^0.6.2:
|
||||
invariant "^2.2.1"
|
||||
prop-types "^15.6.0"
|
||||
|
||||
react-contenteditable@^2.0.7:
|
||||
version "2.0.7"
|
||||
resolved "https://registry.yarnpkg.com/react-contenteditable/-/react-contenteditable-2.0.7.tgz#a8d1c1d7b9a393f336c5ecdb74e5e336d786676b"
|
||||
|
||||
react-dom@>=0.14.0:
|
||||
version "16.2.0"
|
||||
resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-16.2.0.tgz#69003178601c0ca19b709b33a83369fe6124c044"
|
||||
|
||||
Reference in New Issue
Block a user