mirror of
https://github.com/wassname/talk.git
synced 2026-09-09 11:38:08 +08:00
Merge branch 'master' into docs
This commit is contained in:
@@ -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"
|
||||
}
|
||||
Reference in New Issue
Block a user