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

* 'terms-and-conditions' of github.com:coralproject/talk: (136 commits)
  Remaining rename
  Give descriptive key for slot elements and use it instead of talkPluginName
  Add back missing tutorial
  removed unused plugin from directory
  expanded fix to staff replies
  Fix notification responsiveness
  Profile my comments responsive fixes
  Notification fixes
  Remove italic styling from blockquote
  Better disabled detection
  Disable hover on touch devices
  docs update
  Let browser insert text
  Support nested feature instance
  Rename buttons to feature
  Add classNames for styling
  Shortcut support
  Fix append new line after node..
  Better selection behavior
  Remove all unwanted style attr
  ...
This commit is contained in:
okbel
2018-03-30 10:36:33 -03:00
178 changed files with 4724 additions and 10481 deletions
@@ -15,8 +15,11 @@ export default class CheckSpamHook extends React.Component {
// If we haven't check the spam yet, make sure to include `checkSpam=true` in the mutation.
// Otherwise post comment without checking the spam.
if (!this.checked) {
input.checkSpam = true;
this.checked = true;
return {
...input,
checkSpam: true,
};
}
});
@@ -4,8 +4,6 @@ import { t } from 'plugin-api/beta/client/services';
import styles from './TermsAndConditionsField.css';
import cn from 'classnames';
const pluginName = 'talk-plugin-auth-checkbox';
const TermsLink = () => (
<a
className={styles.link}
@@ -31,16 +29,15 @@ class TermsAndConditionsField extends React.Component {
id = 'terms-and-conditions';
componentWillMount() {
this.props.indicateBlockerOn(pluginName);
this.props.indicateBlocker();
}
onChange = ({ target: { checked } }) => {
this.setState({ checked });
if (checked) {
this.setState(() => ({ checked }));
this.props.indicateBlockerOff(pluginName);
this.props.indicateBlockerResolved();
} else {
this.setState(() => ({ checked }));
this.props.indicateBlockerOn(pluginName);
this.props.indicateBlocker();
}
};
@@ -29,6 +29,15 @@ class SignUp extends React.Component {
this.props.onSubmit();
};
childFactory = el => {
const key = el.key;
const props = {
indicateBlocker: () => this.props.indicateBlocker(key),
indicateBlockerResolved: () => this.props.indicateBlockerResolved(key),
};
return React.cloneElement(el, props);
};
render() {
const {
username,
@@ -43,17 +52,9 @@ class SignUp extends React.Component {
errorMessage,
requireEmailConfirmation,
success,
indicateBlockerOn,
indicateBlockerOff,
hasBlockers,
blocked,
} = this.props;
const slotPassthrough = {
indicateBlockerOn,
indicateBlockerOff,
hasBlockers,
};
return (
<div>
<div className={styles.header}>
@@ -115,7 +116,7 @@ class SignUp extends React.Component {
/>
<Slot
fill="talkPluginAuth-formField"
passthrough={slotPassthrough}
childFactory={this.childFactory}
/>
<div className={styles.action}>
<Button
@@ -124,7 +125,7 @@ class SignUp extends React.Component {
id="coralSignUpButton"
className={styles.button}
full
disabled={hasBlockers.length}
disabled={blocked}
>
{t('talk-plugin-auth.login.sign_up')}
</Button>
@@ -176,9 +177,9 @@ SignUp.propTypes = {
errorMessage: PropTypes.string,
requireEmailConfirmation: PropTypes.bool.isRequired,
success: PropTypes.bool.isRequired,
hasBlockers: PropTypes.array.isRequired,
indicateBlockerOn: PropTypes.func.isRequired,
indicateBlockerOff: PropTypes.func.isRequired,
blocked: PropTypes.bool.isRequired,
indicateBlocker: PropTypes.func.isRequired,
indicateBlockerResolved: PropTypes.func.isRequired,
};
export default SignUp;
@@ -16,17 +16,17 @@ class SignUpContainer extends Component {
emailError: null,
passwordError: null,
passwordRepeatError: null,
hasBlockers: [],
blockers: [],
};
indicateBlockerOn = plugin =>
indicateBlocker = key =>
this.setState(state => ({
hasBlockers: state.hasBlockers.concat(plugin),
blockers: state.blockers.concat(key),
}));
indicateBlockerOff = plugin =>
indicateBlockerResolved = key =>
this.setState(state => ({
hasBlockers: state.hasBlockers.filter(i => i !== plugin),
blockers: state.blockers.filter(i => i !== key),
}));
validate = data => {
@@ -59,7 +59,7 @@ class SignUpContainer extends Component {
passwordRepeat: this.state.passwordRepeat,
};
if (this.validate(data) && !this.state.hasBlockers.length) {
if (this.validate(data) && !this.state.blockers.length) {
this.props.signUp(data);
}
};
@@ -87,9 +87,9 @@ class SignUpContainer extends Component {
render() {
return (
<SignUp
indicateBlockerOn={this.indicateBlockerOn}
indicateBlockerOff={this.indicateBlockerOff}
hasBlockers={this.state.hasBlockers}
indicateBlocker={this.indicateBlocker}
indicateBlockerResolved={this.indicateBlockerResolved}
blocked={!!this.state.blockers.length}
onSubmit={this.handleSubmit}
onUsernameChange={this.setUsername}
onEmailChange={this.props.setEmail}
@@ -0,0 +1,23 @@
{
"env": {
"browser": true,
"es6": true,
"mocha": true
},
"parserOptions": {
"sourceType": "module",
"ecmaFeatures": {
"experimentalObjectRestSpread": true,
"jsx": true
}
},
"parser": "babel-eslint",
"plugins": [
"react"
],
"rules": {
"react/jsx-uses-react": "error",
"react/jsx-uses-vars": "error",
"no-console": ["warn", { "allow": ["warn", "error"] }]
}
}
@@ -0,0 +1,39 @@
.container {
display: inline-block;
}
.button {
color: #2a2a2a;
margin: 5px 10px 5px 0px;
background: none;
padding: 0px;
border: none;
font-size: inherit;
vertical-align: middle;
&:hover {
color: #767676;
cursor: pointer;
}
&.downvoted {
color: #cc0000;
&:hover {
color: #ff3232;
cursor: pointer;
}
}
}
.icon {
font-size: 12px;
padding: 0 3px;
}
@media (max-width: 425px) {
.label {
display: none;
}
}
@@ -0,0 +1,56 @@
import React from 'react';
import Icon from './Icon';
import styles from './DownvoteButton.css';
import { withReaction } from 'plugin-api/beta/client/hocs';
import cn from 'classnames';
const plugin = 'talk-plugin-downvote';
class DownvoteButton extends React.Component {
handleClick = () => {
const {
postReaction,
deleteReaction,
showSignInDialog,
alreadyReacted,
user,
} = this.props;
// If the current user does not exist, trigger sign in dialog.
if (!user) {
showSignInDialog();
return;
}
if (alreadyReacted) {
deleteReaction();
} else {
postReaction();
}
};
render() {
const { count, alreadyReacted } = this.props;
return (
<div className={cn(styles.container, `${plugin}-container`)}>
<button
className={cn(
styles.button,
{
[`${
styles.downvoted
} talk-plugin-downvote-downvoted`]: alreadyReacted,
},
`${plugin}-button`
)}
onClick={this.handleClick}
>
<Icon className={cn(styles.icon, `${plugin}-icon`)} />
<span className={cn(`${plugin}-count`)}>{count > 0 && count}</span>
</button>
</div>
);
}
}
export default withReaction('downvote')(DownvoteButton);
@@ -0,0 +1,10 @@
import React from 'react';
import cn from 'classnames';
// @TODO change icon when we deprecate FA
export default ({ className }) => (
<i
className={cn('fa', 'fa-arrow-circle-down', className)}
aria-hidden="true"
/>
);
@@ -0,0 +1,7 @@
import DownvoteButton from './components/DownvoteButton';
export default {
slots: {
commentReactions: [DownvoteButton],
},
};
+2
View File
@@ -0,0 +1,2 @@
const { getReactionConfig } = require('../../plugin-api/beta/server');
module.exports = getReactionConfig('downvote');
@@ -0,0 +1,9 @@
{
"name": "@coralproject/talk-plugin-downvote",
"pluginName": "talk-plugin-downvote",
"version": "0.0.1",
"description": "Downvote comments",
"main": "index.js",
"author": "The Coral Project Team <coral@mozillafoundation.org>",
"license": "Apache-2.0"
}
+2 -2
View File
@@ -11,7 +11,7 @@ plugin:
- Client
---
Enables sign-in via Facebook via the server side passport middleware.
Enables sign-in via Google+ via the server side passport middleware.
You will need to enable the Google+ API in the dashboard and create credentials
for a new OAuth client ID web application. The authorized JavaScript origin
@@ -26,4 +26,4 @@ Configuration:
the [Google API Console](https://console.developers.google.com/apis/).
- `TALK_GOOGLE_CLIENT_SECRET` (**required**) - The Google OAuth2 client ID for
your Google login web app. You can learn more about getting a Google Client
ID at the [Google API Console](https://console.developers.google.com/apis/).
ID at the [Google API Console](https://console.developers.google.com/apis/).
@@ -45,9 +45,9 @@ export default class ModerationActions extends React.Component {
)}
</span>
{menuVisible && (
<Menu className="talk-plugin-modetarion-actions-menu">
<Menu className="talk-plugin-moderation-actions-menu">
<Slot
className="talk-plugin-modetarion-actions-slot"
className="talk-plugin-moderation-actions-slot"
fill="moderationActions"
passthrough={slotPassthrough}
/>
@@ -2,6 +2,12 @@ const { get } = require('lodash');
const path = require('path');
const handle = async (ctx, comment) => {
// Check to see if this reply is visible.
if (!comment.visible) {
ctx.log.info('comment was not visible, not sending notification');
return;
}
// Check to see if this is a reply to an existing comment.
const parentID = get(comment, 'parent_id', null);
if (parentID === null) {
@@ -90,12 +96,31 @@ const hydrate = async (ctx, category, context) => {
return [headline, replier, permalink];
};
const handler = {
handle,
category: 'reply',
event: 'commentAdded',
hydrate,
digestOrder: 30,
// commentAcceptedHandleAdapter will check to see if we need to send a
// notification for this comment if the comment has been recently approved but
// has not been approved before.
const commentAcceptedHandleAdapter = (ctx, comment) => {
// Don't send a notification for a non-visible comment.
if (!comment.visible) {
ctx.log.info('comment was not visible, not sending notification');
return;
}
// Don't send a notification if the comment was previously visible.
if (
// TODO: (wyattjoh) this check is quite brittle, replace with a more concrete check.
comment.status_history
.slice(0, comment.status_history.length - 1)
.some(({ type }) => ['ACCEPTED', 'NONE'].includes(type))
) {
ctx.log.info(
'comment was previously already visible, not sending another notification'
);
return;
}
// Delegate to the handle function.
return handle(ctx, comment);
};
module.exports = {
@@ -115,5 +140,20 @@ module.exports = {
},
},
translations: path.join(__dirname, 'translations.yml'),
notifications: [handler],
notifications: [
{
handle,
category: 'reply',
event: 'commentAdded',
hydrate,
digestOrder: 30,
},
{
handle: commentAcceptedHandleAdapter,
category: 'reply',
event: 'commentAccepted',
hydrate,
digestOrder: 30,
},
],
};
@@ -2,6 +2,11 @@ const { get } = require('lodash');
const path = require('path');
const handle = async (ctx, comment) => {
if (!comment.visible) {
ctx.log.info('comment was not visible, not sending notification');
return;
}
// Check to see if this is a reply to an existing comment.
const parentID = get(comment, 'parent_id', null);
if (parentID === null) {
@@ -111,13 +116,31 @@ const hydrate = async (ctx, category, context) => {
return [headline, replier, organizationName, permalink];
};
const handler = {
handle,
category: 'staff',
event: 'commentAdded',
hydrate,
supersedesCategories: ['reply'],
digestOrder: 20,
// commentAcceptedHandleAdapter will check to see if we need to send a
// notification for this comment if the comment has been recently approved but
// has not been approved before.
const commentAcceptedHandleAdapter = (ctx, comment) => {
// Don't send a notification for a non-visible comment.
if (!comment.visible) {
ctx.log.info('comment was not visible, not sending notification');
return;
}
// Don't send a notification if the comment was previously visible.
if (
// TODO: (wyattjoh) this check is quite brittle, replace with a more concrete check.
comment.status_history
.slice(0, comment.status_history.length - 1)
.some(({ type }) => ['ACCEPTED', 'NONE'].includes(type))
) {
ctx.log.info(
'comment was previously already visible, not sending another notification'
);
return;
}
// Delegate to the handle function.
return handle(ctx, comment);
};
module.exports = {
@@ -137,5 +160,22 @@ module.exports = {
},
},
translations: path.join(__dirname, 'translations.yml'),
notifications: [handler],
notifications: [
{
handle,
category: 'staff',
event: 'commentAdded',
hydrate,
supersedesCategories: ['reply'],
digestOrder: 20,
},
{
handle: commentAcceptedHandleAdapter,
category: 'staff',
event: 'commentAccepted',
hydrate,
supersedesCategories: ['reply'],
digestOrder: 20,
},
],
};
+12 -2
View File
@@ -15,7 +15,17 @@ anything. You need to enable one of the `talk-plugin-notifications-category-*` p
Configuration:
- `DISABLE_REQUIRE_EMAIL_VERIFICATIONS` - When `TRUE`, it will disable the verification email check before sending notifications for those emails. **Note that organizations implementing a custom authentication system _must_ disable this feature, as they don't use our integrated auth**. (Default `FALSE`).
- `TALK_DISABLE_REQUIRE_EMAIL_VERIFICATIONS_NOTIFICATIONS` - When `TRUE`, it will disable the verification email check before sending notifications for those emails. **Note that organizations implementing a custom authentication system _must_ disable this feature, as they don't use our integrated auth**. (Default `FALSE`).
- `TALK_CLIENT_FORCE_NOTIFICATION_SETTINGS` - When `TRUE`, the settings pane for notifications will show always, even if the user does not have a `local` profile. (Default `FALSE`).
You can enable other notification options by adding more
`talk-plugin-notification-*` plugins!
`talk-plugin-notification-*` plugins!
## Email Subjects
While it seems in your notification category plugin you can set the subject
line by adjusting the translation, Talk's default behavior is to add a prefix
before the subject of each email sent. This is always set to the
[TALK-EMAIL-SUBJECT-PREFIX](/talk/advanced-configuration/#TALK-EMAIL-SUBJECT-PREFIX)
configuration variable. You should change this parameter if you want to affect
how the subject is rendered.
@@ -1,6 +1,6 @@
.root {
margin-bottom: 20px;
width: 350px;
max-width: 350px;
}
.innerSettings {
@@ -14,10 +14,10 @@ import cn from 'classnames';
class Settings extends React.Component {
childFactory = el => {
const pluginName = el.type.talkPluginName;
const key = el.key;
const props = {
indicateOn: () => this.props.indicateOn(pluginName),
indicateOff: () => this.props.indicateOff(pluginName),
indicateOn: () => this.props.indicateOn(key),
indicateOff: () => this.props.indicateOff(key),
};
return React.cloneElement(el, props);
};
@@ -9,6 +9,8 @@ import {
} from 'plugin-api/beta/client/hocs';
import { getSlotFragmentSpreads } from 'plugin-api/beta/client/utils';
import { withUpdateNotificationSettings } from '../mutations';
import { connect } from 'plugin-api/beta/client/hocs';
import { staticConfigSelector } from 'plugin-api/beta/client/selectors';
const slots = ['notificationSettings'];
@@ -18,14 +20,14 @@ class SettingsContainer extends React.Component {
turnOffInput: {},
};
indicateOn = plugin =>
indicateOn = key =>
this.setState(state => ({
hasNotifications: state.hasNotifications.concat(plugin),
hasNotifications: state.hasNotifications.concat(key),
}));
indicateOff = plugin =>
indicateOff = key =>
this.setState(state => ({
hasNotifications: state.hasNotifications.filter(i => i !== plugin),
hasNotifications: state.hasNotifications.filter(i => i !== key),
}));
setTurnOffInputFragment = fragment =>
@@ -42,8 +44,11 @@ class SettingsContainer extends React.Component {
};
getNeedEmailVerification() {
return !this.props.root.me.profiles.some(
profile => profile.provider === 'local' && profile.confirmedAt
return (
this.props.root.settings.notificationsRequireConfirmation &&
!this.props.root.me.profiles.some(
profile => profile.provider === 'local' && profile.confirmedAt
)
);
}
@@ -94,11 +99,22 @@ const enhance = compose(
}
}
}
settings {
notificationsRequireConfirmation
}
}
`,
}),
// Grab the static configuration from the redux store.
connect(state => ({
static: staticConfigSelector(state),
})),
excludeIf(
props =>
// If the environment variable for TALK_CLIENT_FORCE_NOTIFICATION_SETTINGS
// is `TRUE`, then always show it.
props.static.TALK_CLIENT_FORCE_NOTIFICATION_SETTINGS !== 'TRUE' &&
// Only show the settings pane if we have a local profile otherwise.
!props.root.me.profiles.some(profile => profile.provider === 'local')
),
withUpdateNotificationSettings,
@@ -63,8 +63,6 @@ const sendNotificationsBatch = async (ctx, notifications) => {
return;
}
console.log(notifications);
return Promise.all(
map(
notifications,
@@ -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" />
);
@@ -1,49 +0,0 @@
---
title: talk-plugin-rich-text-pell
permalink: /plugin/talk-plugin-rich-text-pell/
layout: plugin
plugin:
name: talk-plugin-rich-text-pell
depends:
- name: talk-plugin-rich-text
provides:
- Client
---
Enables rich text support client-side by using [Pell](https://github.com/jaredreich/pell).
## Installation
Add `"talk-plugin-rich-text-pell"` to the `plugins.json` in your Talk
installation. Remember to add this in the `client` property since this plugin
only covers the client side. To add server support, please use
[talk-plugin-rich-text](/talk/plugin/talk-plugin-rich-text).
_Note: Ensure that you don't have any other plugins utilizing the
`commentContent` slot, as it would result in duplicate comments._
## 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 contains the rich text editor. For this particular plugin
we chose [Pell](https://github.com/jaredreich/pell). Pell is the simplest and
smallest WYSIWYG text editor with no dependencies that we could find.
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.
@@ -1,41 +0,0 @@
.content {
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;
}
.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;
}
.actionBar {
user-select: none;
padding: 5px 10px;
border-top: 1px solid #bbb;
border-left: 1px solid #bbb;
border-right: 1px solid #bbb;
}
.container {
box-sizing: border-box;
}
@@ -1,108 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { init } from 'pell';
import styles from './Editor.css';
import cn from 'classnames';
import { pluginName } from '../../package.json';
import { htmlNormalizer } from '../utils';
class Editor extends React.Component {
ref = null;
handleRef = ref => (this.ref = ref);
componentDidMount() {
const { onChange, actions, classNames, isReply } = this.props;
init({
element: this.ref,
onChange: richTextBody => {
// We want to save the original comment body
const originalBody = this.ref.childNodes[1].innerText;
onChange(originalBody, { richTextBody: htmlNormalizer(richTextBody) });
},
actions,
classes: {
actionbar: cn(
styles.actionBar,
classNames.actionbar,
`${pluginName}-action-bar`
),
content: cn(
styles.content,
classNames.content,
`${pluginName}-content`
),
button: cn(styles.button, classNames.button, `${pluginName}-button`),
},
});
// To edit comments and have the previous html comment
if (this.props.comment && this.props.comment.richTextBody && !isReply) {
this.ref.content.innerHTML = this.props.comment.richTextBody;
}
if (this.props.registerHook) {
this.clearInputHook = this.props.registerHook(
'postSubmit',
(res, handleBodyChange) => {
this.ref.content.innerHTML = '';
handleBodyChange('', { richTextBody: '' });
}
);
}
}
componentWillUnmount() {
this.props.unregisterHook(this.clearInputHook);
}
render() {
const { id, classNames } = this.props;
return (
<div
id={id}
ref={this.handleRef}
className={cn(
styles.container,
classNames.container,
`${pluginName}-container`
)}
/>
);
}
}
Editor.defaultProps = {
defaultContent: '',
styleWithCSS: false,
actions: [
{ name: 'bold', icon: '<i class="material-icons">format_bold</i>' },
{ name: 'italic', icon: '<i class="material-icons">format_italic</i>' },
{ name: 'quote', icon: '<i class="material-icons">format_quote</i>' },
],
classNames: {
button: '',
content: '',
actionbar: '',
container: '',
},
};
Editor.propTypes = {
id: PropTypes.string,
value: PropTypes.string,
placeholder: PropTypes.string,
onChange: PropTypes.func,
disabled: PropTypes.bool,
rows: PropTypes.number,
comment: PropTypes.object,
classNames: PropTypes.object,
actions: PropTypes.array,
registerHook: PropTypes.func,
unregisterHook: PropTypes.func,
isReply: PropTypes.bool,
};
export default Editor;
@@ -1,20 +0,0 @@
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
// Remove first opening tag, otherwise
// with the following transformation below
// we might add an unintended first empty line.
if (str.startsWith('<div>')) {
str = str.replace('<div>', '');
}
// Normalize <div>s to <br>.
return str.replace(/<div>/g, '<br>').replace(/<\/div>/g, '');
}
@@ -1,12 +0,0 @@
{
"name": "@coralproject/talk-plugin-rich-text-pell",
"pluginName": "talk-plugin-rich-text-pell",
"version": "0.0.1",
"description": "Pell's Rich Text Editor for Talk",
"main": "index.js",
"author": "The Coral Project Team <coral@mozillafoundation.org>",
"license": "Apache-2.0",
"dependencies": {
"pell": "^1.0.1"
}
}
+36 -9
View File
@@ -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 wouldnt 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,4 @@
.content {
composes: content from "./CommentContent.css";
}
@@ -0,0 +1,27 @@
import React from 'react';
import PropTypes from 'prop-types';
import styles from './AdminCommentContent.css';
import { AdminCommentContent as Content } from 'plugin-api/beta/client/components';
class AdminCommentContent extends React.Component {
render() {
const { comment, suspectWords, bannedWords } = this.props;
return (
<Content
className={styles.content}
body={comment.richTextBody ? comment.richTextBody : comment.body}
suspectWords={suspectWords}
bannedWords={bannedWords}
html={!!comment.richTextBody}
/>
);
}
}
AdminCommentContent.propTypes = {
comment: PropTypes.object.isRequired,
suspectWords: PropTypes.array.isRequired,
bannedWords: PropTypes.array.isRequired,
};
export default AdminCommentContent;
@@ -0,0 +1,14 @@
.content {
blockquote {
background-color: #F6F6F6;
padding: 10px;
margin: 20px 0px 20px 10px;
border-radius: 2px;
&::after {
content: none;
}
&::before {
content: none;
}
}
}
@@ -1,17 +1,20 @@
import React from 'react';
import PropTypes from 'prop-types';
import { pluginName } from '../../package.json';
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={`${pluginName}-text`}
className={className}
dangerouslySetInnerHTML={{ __html: comment.richTextBody }}
/>
) : (
<div className={`${pluginName}-text`}>{comment.body}</div>
<div className={className}>{comment.body}</div>
);
}
}
@@ -0,0 +1,13 @@
.commentContent {
composes: content from "./CommentContent.css";
}
.placeholder {
position: absolute;
margin: 12px 0 0 12px;
color: #bbb;
}
.icon {
font-size: 20px;
}
@@ -0,0 +1,122 @@
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 RTE from './rte/RTE';
import { Icon } from 'plugin-api/beta/client/components/ui';
import { Bold, Italic, Blockquote } from './rte/features';
import { t } from 'plugin-api/beta/client/services';
class Editor extends React.Component {
ref = null;
handleRef = ref => (this.ref = ref);
handleChange = c => {
this.props.onInputChange({
body: c.text,
richTextBody: c.html,
});
};
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),
};
}
});
}
if (this.props.isReply) {
this.ref.focus();
}
}
componentWillUnmount() {
this.props.unregisterHook(this.normalizeHook);
}
render() {
const { id, placeholder, label, disabled } = this.props;
const inputId = `${id}-rte`;
return (
<div className={cn(styles.root, `${PLUGIN_NAME}-container`)}>
<label
htmlFor={inputId}
className="screen-reader-text"
aria-hidden={true}
>
{label}
</label>
<RTE
inputId={inputId}
className={`${PLUGIN_NAME}-editor`}
classNameDisabled={`${PLUGIN_NAME}-editor-disabled`}
contentClassName={cn(`${PLUGIN_NAME}-content`, styles.commentContent)}
contentClassNameDisabled={`${PLUGIN_NAME}-content-disabled`}
toolbarClassName={`${PLUGIN_NAME}-toolbar`}
toolbarClassNameDisabled={`${PLUGIN_NAME}-toolbar-disabled`}
onChange={this.handleChange}
value={this.getHTML()}
disabled={disabled}
placeholder={placeholder}
ref={this.handleRef}
features={[
<Bold
key="bold"
title={t('talk-plugin-rich-text.format_bold')}
className={`${PLUGIN_NAME}-feature-bold`}
>
<Icon className={styles.icon} name="format_bold" />
</Bold>,
<Italic
key="italic"
title={t('talk-plugin-rich-text.format_italic')}
className={`${PLUGIN_NAME}-feature-italic`}
>
<Icon className={styles.icon} name="format_italic" />
</Italic>,
<Blockquote
key="blockquote"
title={t('talk-plugin-rich-text.format_blockquote')}
className={`${PLUGIN_NAME}-feature-blockquote`}
>
<Icon className={styles.icon} name="format_quote" />
</Blockquote>,
]}
/>
</div>
);
}
}
Editor.propTypes = {
input: PropTypes.object,
placeholder: PropTypes.string,
onInputChange: PropTypes.func,
disabled: PropTypes.bool,
comment: 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,29 @@
.contentEditable {
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;
margin-bottom: 3px;
}
.placeholder {
position: absolute;
margin: 12px 0 0 12px;
color: #bbb;
}
.toolbarDisabled {
background: #f8f8f8;
cursor: default;
}
.contentEditableDisabled {
background: #fafafa;
color: #888;
cursor: default;
}
@@ -0,0 +1,392 @@
import React from 'react';
import PropTypes from 'prop-types';
import styles from './RTE.css';
import cn from 'classnames';
import ContentEditable from 'react-contenteditable';
import Toolbar from './components/Toolbar';
import {
insertNewLine,
insertText,
getSelectionRange,
replaceSelection,
cloneNodeAndRange,
replaceNodeChildren,
selectEndOfNode,
isSelectionInside,
traverse,
} from './lib/dom';
import createAPI from './lib/api';
import Undo from './lib/undo';
import bowser from 'bowser';
import throttle from 'lodash/throttle';
class RTE extends React.Component {
/// Ref to react-contenteditable
ref = null;
// Our "plugins" api.
api = createAPI(
() => this.ref.htmlEl,
() => this.handleChange(),
() => this.undo.canUndo(),
() => this.undo.canRedo(),
() => this.handleUndo(),
() => this.handleRedo(),
() => this.focused
);
// Instance of undo stack.
undo = new Undo();
// Refs to the features.
featuresRef = {};
// Export this for parent components.
focus = () => this.ref.htmlEl.focus();
unmounted = false;
focused = false;
// Should be called on every change to feed
// our Undo stack. We save the innerHTML and if available
// a copy of the contentEditable node and a copy of the range.
saveCheckpoint = throttle((html, node, range) => {
const args = [html];
if (node && range) {
args.push(...cloneNodeAndRange(node, range));
}
this.undo.save(...args);
}, 1000);
constructor(props) {
super(props);
this.saveCheckpoint(props.value);
}
// Returns a handler that fills our `featuresRef`.
createFeatureRefHandler(key) {
return ref => {
if (ref) {
this.featuresRef[key] = ref;
} else {
delete this.featuresRef[key];
}
};
}
// Ref to react-contenteditable.
handleRef = ref => (this.ref = ref);
forEachFeature(callback) {
Object.keys(this.featuresRef).map(k => {
const instance = this.featuresRef[k].getFeatureInstance
? this.featuresRef[k].getFeatureInstance()
: this.featuresRef[k];
callback(instance);
});
}
componentWillReceiveProps(props) {
// Clear undo stack if content was set to sth different.
if (props.value !== this.ref.htmlEl.innerHTML) {
this.undo.clear();
this.saveCheckpoint(props.value);
if (isSelectionInside(this.ref.htmlEl)) {
setTimeout(() => !this.unmounted && selectEndOfNode(this.ref.htmlEl));
}
}
}
componentWillUnmount() {
// Cancel pending stuff.
this.saveCheckpoint.cancel();
this.unmounted = true;
}
handleChange = () => {
// TODO: don't rely on this hack.
// It removes all `style` attr that
// remaining execCommand still add.
traverse(this.ref.htmlEl, n => {
n.removeAttribute && n.removeAttribute('style');
});
this.props.onChange({
text: this.ref.htmlEl.innerText,
html: this.ref.htmlEl.innerHTML,
});
this.ref.htmlEl.focus();
this.saveCheckpoint(
this.ref.htmlEl.innerHTML,
this.ref.htmlEl,
getSelectionRange()
);
};
handleSelectionChange = () => {
// Let features know selection has changeed, so they
// can update.
this.forEachFeature(b => {
b.onSelectionChange && b.onSelectionChange();
});
};
// Allow features to handle shortcuts.
handleShortcut = e => {
let handled = false;
this.forEachFeature(b => {
if (!handled) {
handled = !!(b.onShortcut && b.onShortcut(e));
}
});
return handled;
};
// Called when Enter was pressed without shift.
// Traverses from bottom to top and calling
// feature handlers and stops when one has handled this event.
handleSpecialEnter = () => {
let handled = false;
const sel = window.getSelection();
const range = sel.getRangeAt(0);
let container = range.startContainer;
while (!handled && container && container !== this.ref.htmlEl) {
this.forEachFeature(b => {
if (!handled) {
handled = !!(b.onEnter && b.onEnter(container));
}
});
container = container.parentNode;
}
return handled;
};
handleCut = () => {
// IE has issues not firing the onChange event.
if (bowser.msie) {
setTimeout(() => !this.unmounted && this.handleChange());
}
};
handleFocus = () => {
this.focused = true;
};
handleBlur = () => {
this.focused = false;
// Sometimes the onselect event doesn't fire on blur.
this.handleSelectionChange();
};
// We intercept pasting, so that we
// force text/plain content.
handlePaste = e => {
// Get text representation of clipboard
// This works cross browser.
const text = (
(e.originalEvent || e).clipboardData || window.clipboardData
).getData('Text');
// IE does this funny thing to change the selection after the paste
// event, remember the range for now.
const range = getSelectionRange().cloneRange();
// Run outside of event loop to fix
// selection issues with IE.
setTimeout(() => {
// Manually delete range, cope with IE.
if (!range.collapsed) {
range.deleteContents();
}
// insert text manually
insertText(text);
this.handleChange();
});
e.preventDefault();
return false;
};
handleKeyDown = e => {
// IE has issues not firing the onChange event.
if (bowser.msie) {
setTimeout(() => !this.unmounted && this.handleChange());
}
// Undo Redo 'Z'
if (e.key === 'z' && (e.metaKey || e.ctrlKey)) {
if (e.shiftKey) {
this.handleRedo();
} else {
this.handleUndo();
}
e.preventDefault();
return false;
}
if (e.metaKey || e.ctrlKey) {
if (this.handleShortcut(e)) {
e.preventDefault();
return false;
}
}
// Newlines Or Special Enter Behaviors.
if (e.key === 'Enter') {
if (!e.shiftKey && this.handleSpecialEnter()) {
this.handleChange();
e.preventDefault();
return false;
}
insertNewLine(true);
this.handleChange();
e.preventDefault();
return false;
}
};
restoreCheckpoint(html, node, range) {
if (node && range) {
// We need to clone it, otherwise we'll mutate
// that original one which can still be in the undo stack.
const [nodeCloned, rangeCloned] = cloneNodeAndRange(node, range);
// Remember range values, as `rangeCloned` can changed during
// DOM manipulation.
const startOffset = rangeCloned.startOffset;
const endOffset = rangeCloned.startOffset;
// Rewrite startContainer if it was pointing to `nodeCloned`.
const startContainer =
rangeCloned.startContainer === nodeCloned
? this.ref.htmlEl
: rangeCloned.startContainer;
// Rewrite endContainer if it was pointing to `nodeCloned`.
const endContainer =
rangeCloned.endContainer === nodeCloned
? this.ref.htmlEl
: rangeCloned.endContainer;
// Replace children with the ones from nodeCloned.
replaceNodeChildren(this.ref.htmlEl, nodeCloned);
// Now setup the selection range.
const finalRange = document.createRange();
finalRange.setStart(startContainer, startOffset);
finalRange.setEnd(endContainer, endOffset);
// SELECT!
replaceSelection(finalRange);
} else {
this.ref.htmlEl.innerHTML = html;
selectEndOfNode(this.ref.htmlEl);
}
this.handleChange();
}
handleUndo() {
this.saveCheckpoint.flush();
if (this.undo.canUndo()) {
const [html, node, range] = this.undo.undo();
this.restoreCheckpoint(html, node, range);
}
}
handleRedo() {
this.saveCheckpoint.flush();
if (this.undo.canRedo()) {
const [html, node, range] = this.undo.redo();
this.restoreCheckpoint(html, node, range);
}
}
renderFeatures() {
return this.props.features.map(b => {
return React.cloneElement(b, {
disabled: this.props.disabled,
api: this.api,
ref: this.createFeatureRefHandler(b.key),
});
});
}
getClassNames() {
const { disabled } = this.props;
return {
toolbar: cn(this.props.toolbarClassName, {
[this.props.toolbarClassNameDisabled]: disabled,
[styles.toolbarDisabled]: disabled,
}),
content: cn(styles.contentEditable, this.props.contentClassName, {
[this.props.contentClassNameDisabled]: disabled,
[styles.contentEditableDisabled]: disabled,
}),
root: cn(this.props.className, {
[this.props.classNameDisabled]: disabled,
}),
placeholder: cn(styles.placeholder, this.props.placeholderClassName, {
[this.props.placeholderClassNameDisabled]: disabled,
}),
};
}
render() {
const { value, placeholder, inputId, disabled } = this.props;
const classNames = this.getClassNames();
return (
<div className={classNames.root}>
<Toolbar className={classNames.toolbar}>
{this.renderFeatures()}
</Toolbar>
{!value && <div className={classNames.placeholder}>{placeholder}</div>}
<ContentEditable
id={inputId}
onMouseUp={this.handleMouseUp}
onKeyDown={this.handleKeyDown}
onKeyPress={this.handleKeyPress}
onKeyUp={this.handleKeyUp}
onPaste={this.handlePaste}
onCut={this.handleCut}
onFocus={this.handleFocus}
onBlur={this.handleBlur}
onSelect={this.handleSelectionChange}
className={classNames.content}
ref={this.handleRef}
html={value}
disabled={disabled}
onChange={this.handleChange}
/>
</div>
);
}
}
RTE.defaultProps = {
features: [],
};
RTE.propTypes = {
features: PropTypes.array,
inputId: PropTypes.string,
input: PropTypes.object,
onChange: PropTypes.func,
disabled: PropTypes.bool,
className: PropTypes.string,
classNameDisabled: PropTypes.string,
contentClassName: PropTypes.string,
contentClassNameDisabled: PropTypes.string,
toolbarClassName: PropTypes.string,
toolbarClassNameDisabled: PropTypes.string,
placeholderClassName: PropTypes.string,
placeholderClassNameDisabled: PropTypes.string,
placeholder: PropTypes.string,
value: PropTypes.string,
};
export default RTE;
@@ -0,0 +1,51 @@
.buttonReset {
user-select: none;
outline: invert none medium;
border: none;
touch-action: manipulation;
padding: 0;
overflow: hidden;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0) !important;
&::-moz-focus-inner: {
border: 0;
}
}
.button > i {
vertical-align: middle;
}
.button {
composes: buttonReset;
background-color: transparent;
padding: 3px;
border: none;
color: #4e4e4e;
margin-right: 3px;
border-radius: 3px;
}
.button:hover {
cursor: pointer;
background-color: #eae8e8;
}
.button.active {
background-color: #ddd;
}
.button:disabled{
color: #bbb;
cursor: default;
background: none;
}
@media (-moz-touch-enabled: 1), (pointer:coarse) {
.button:hover{
background-color: transparent;
}
.button.active {
background-color: #ddd;
}
}
@@ -0,0 +1,42 @@
import React from 'react';
import PropTypes from 'prop-types';
import styles from './Button.css';
import cn from 'classnames';
class Button extends React.Component {
render() {
const {
className,
title,
onClick,
children,
active,
activeClassName,
disabled,
} = this.props;
return (
<button
className={cn(className, styles.button, {
[cn(styles.active, activeClassName)]: active,
})}
title={title}
onClick={onClick}
disabled={disabled}
>
{children}
</button>
);
}
}
Button.propTypes = {
className: PropTypes.string,
activeClassName: PropTypes.string,
title: PropTypes.string,
onClick: PropTypes.func,
children: PropTypes.node,
active: PropTypes.bool,
disabled: PropTypes.bool,
};
export default Button;
@@ -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,85 @@
import React from 'react';
import PropTypes from 'prop-types';
import Button from '../components/Button';
/**
* createToggle creates a button that can be active, inactive or disabled
* and reacts on clicks. All callbacks are bound to the API instance.
*/
const createToggle = (
execCommand,
{ onEnter, onShortcut, isActive = () => false, isDisabled = () => false } = {}
) => {
class Toggle extends React.Component {
state = {
active: false,
disabled: false,
};
execCommand = () => execCommand.apply(this.props.api);
isActive = () => isActive.apply(this.props.api);
isDisabled = () => isDisabled.apply(this.props.api);
onEnter = (...args) => onEnter && onEnter.apply(this.props.api, args);
onShortcut = (...args) =>
onShortcut && onShortcut.apply(this.props.api, args);
unmounted = false;
componentWillUnmount() {
this.unmounted = true;
}
formatToggle = () => {
this.execCommand();
};
handleClick = () => {
this.props.api.focus();
this.formatToggle();
this.props.api.focus();
setTimeout(() => !this.unmounted && this.syncState());
};
syncState = () => {
if (this.state.active !== this.isActive()) {
this.setState(state => ({
active: !state.active,
}));
}
if (this.state.disabled !== this.isDisabled()) {
this.setState(state => ({
disabled: !state.disabled,
}));
}
};
onSelectionChange() {
this.syncState();
}
render() {
const { className, title, children, disabled } = this.props;
return (
<Button
className={className}
title={title}
onClick={this.handleClick}
active={this.state.active}
disabled={disabled || this.state.disabled}
>
{children}
</Button>
);
}
}
Toggle.propTypes = {
api: PropTypes.object,
className: PropTypes.string,
title: PropTypes.string,
children: PropTypes.node,
disabled: PropTypes.bool,
};
return Toggle;
};
export default createToggle;
@@ -0,0 +1,49 @@
import createToggle from '../factories/createToggle';
import {
findIntersecting,
insertNewLineAfterNode,
insertNodes,
getSelectedNodesExpanded,
outdentBlock,
selectEndOfNode,
indentNodes,
} from '../lib/dom';
function execCommand() {
const bq = findIntersecting('BLOCKQUOTE', this.container);
if (bq) {
outdentBlock(bq, true);
} else {
// Expanded selection means we always select whole lines.
const selectedNodes = getSelectedNodesExpanded();
if (selectedNodes.length) {
indentNodes(selectedNodes, 'blockquote', true);
} else {
const node = document.createElement('blockquote');
node.appendChild(document.createElement('br'));
insertNodes(node);
selectEndOfNode(node);
}
}
this.broadcastChange();
}
function isActive() {
return this.focused && !!findIntersecting('BLOCKQUOTE', this.container);
}
function onEnter(node) {
if (node.tagName !== 'BLOCKQUOTE') {
return;
}
insertNewLineAfterNode(node, true);
return true;
}
const Blockquote = createToggle(execCommand, { onEnter, isActive });
Blockquote.defaultProps = {
children: 'Blockquote',
};
export default Blockquote;
@@ -0,0 +1,44 @@
import createToggle from '../factories/createToggle';
import { findIntersecting, findAncestor } from '../lib/dom';
const boldTags = ['B', 'STRONG'];
function execCommand() {
return document.execCommand('bold');
}
function isActive() {
return this.focused && document.queryCommandState('bold');
}
function isDisabled() {
if (!this.focused) {
return false;
}
// Disable whenever the bold styling came from a different
// tag than those we control.
return !!findIntersecting(
n =>
n.nodeName !== '#text' &&
window.getComputedStyle(n).getPropertyValue('font-weight') === 'bold' &&
!boldTags.includes(n.tagName) &&
!findAncestor(n, n => boldTags.includes(n.tagName), this.container),
this.container
);
}
function onShortcut(e) {
if (e.key === 'b') {
if (!isDisabled.apply(this)) {
execCommand.apply(this);
}
return true;
}
}
const Bold = createToggle(execCommand, { isActive, isDisabled, onShortcut });
Bold.defaultProps = {
children: 'Bold',
};
export default Bold;
@@ -0,0 +1,42 @@
import createToggle from '../factories/createToggle';
import { findIntersecting, findAncestor } from '../lib/dom';
const italicTags = ['I', 'EM'];
function execCommand() {
return document.execCommand('italic');
}
function isActive() {
return this.focused && document.queryCommandState('italic');
}
function isDisabled() {
if (!this.focused) {
return false;
}
// Disable whenever the italic styling came from a different
// tag than those we control.
return !!findIntersecting(
n =>
n.nodeName !== '#text' &&
window.getComputedStyle(n).getPropertyValue('font-style') === 'italic' &&
!italicTags.includes(n.tagName) &&
!findAncestor(n, n => italicTags.includes(n.tagName), this.container),
this.container
);
}
function onShortcut(e) {
if (e.key === 'i') {
if (!isDisabled.apply(this)) {
execCommand.apply(this);
}
return true;
}
}
const Italic = createToggle(execCommand, { isActive, isDisabled, onShortcut });
Italic.defaultProps = {
children: 'Italic',
};
export default Italic;
@@ -0,0 +1,3 @@
export { default as Bold } from './Bold';
export { default as Italic } from './Italic';
export { default as Blockquote } from './Blockquote';
@@ -0,0 +1,37 @@
import { isSelectionInside } from './dom';
/**
* An instance of API is passed to all the buttons to
* interact with RTE, which servers as a clean abstraction.
*/
function createAPI(
getContainer,
broadcastChange,
canUndo,
canRedo,
undo,
redo,
getFocused
) {
return {
broadcastChange,
canUndo,
canRedo,
undo,
redo,
get focused() {
return getFocused();
},
get container() {
return getContainer();
},
focus() {
this.container.focus();
},
isSelectionInside() {
return isSelectionInside(getContainer());
},
};
}
export default createAPI;
@@ -0,0 +1,681 @@
/**
* Traverse DOM tree until callback returns anything.
*/
export function traverse(node, callback) {
let result;
for (let i = 0; i < node.childNodes.length; i++) {
const child = node.childNodes[i];
result = callback(child);
if (result === undefined) {
result = traverse(child, callback);
}
if (result !== undefined) {
return result;
}
}
}
/**
* Traverse DOM tree backwards until callback returns anything.
* If hits limitTo returns null.
*/
export function traverseUp(node, callback, limitTo) {
let result;
if (node.isSameNode(limitTo)) {
return null;
}
while (node.parentNode) {
node = node.parentNode;
result = callback(node);
if (result !== undefined) {
return result;
}
if (limitTo && node.isSameNode(limitTo)) {
return null;
}
}
}
/**
* Find ancestor with given tag or whith callback returning true.
* If `limitTo` is passed, the search is limited to this container.
*/
export function findAncestor(node, tagOrCallback, limitTo) {
const callback =
typeof tagOrCallback === 'function'
? tagOrCallback
: n => n.tagName === tagOrCallback;
return (
traverseUp(
node,
n => {
if (callback(n)) {
return n;
}
},
limitTo
) || null
);
}
/**
* Find child with given tag or when callback return true.
*/
export function findChild(node, tagOrCallback) {
const callback =
typeof tagOrCallback === 'function'
? tagOrCallback
: n => n.tagName === tagOrCallback;
return (
traverse(node, n => {
if (callback(n)) {
return n;
}
}) || null
);
}
/**
* Find an node intersecting with the selection with given tag or
* with callback returning true. If `limitTo` is passed, the search
* is limited to this container.
*/
export function findIntersecting(tagOrCallback, limitTo) {
const callback =
typeof tagOrCallback === 'function'
? tagOrCallback
: n => n.tagName === tagOrCallback;
const range = getSelectionRange();
if (!range) {
return null;
}
if (callback(range.startContainer)) {
return range.startContainer;
}
const ancestor = findAncestor(range.startContainer, callback, limitTo);
if (ancestor) {
return ancestor;
}
const nodes = getSelectedChildren(range.commonAncestorContainer);
for (let i = 0; i < nodes.length; i++) {
if (callback(nodes[i])) {
return nodes[i];
}
const found = findChild(nodes[i], callback);
if (found) {
return found;
}
}
return null;
}
/**
* Same as node.contains but works in IE.
* In addition lookFor can also be a callback.
*/
export function nodeContains(node, lookFor) {
const callback =
typeof lookFor === 'function' ? lookFor : n => n.isSameNode(lookFor);
if (callback(node)) {
return true;
}
return !!findChild(node, callback);
}
/**
* Returns true if node is not `inline` nor `inline-block`.
*/
export function isBlockElement(node) {
if (node.nodeName === '#text') {
return false;
}
return !window
.getComputedStyle(node)
.getPropertyValue('display')
.startsWith('inline');
}
/**
* Find parent that is a block element.
*/
export function findParentBlock(node) {
return findAncestor(node, isBlockElement);
}
/**
* Find last parent before a block element.
*/
export function lastParentBeforeBlock(node) {
return findAncestor(node, n => !n.parentNode || isBlockElement(n.parentNode));
}
/**
* Like `Array.indexOf` but works on `childNodes`.
*/
export function indexOfChildNode(parent, child) {
for (let i = 0; i < parent.childNodes.length; i++) {
if (parent.childNodes[i] === child) {
return i;
}
}
return -1;
}
/**
* Same as `document.execCommand('insertText', false, text)` but also
* works for IE. Changes Selection.
*/
export function insertText(text) {
const selection = window.getSelection();
const range = selection.getRangeAt(0);
if (!range.collapsed) {
range.deleteContents();
}
const newRange = document.createRange();
const offset = range.startOffset;
const container = range.startContainer;
if (container.nodeName === '#text') {
container.textContent =
container.textContent.slice(0, offset) +
text +
container.textContent.slice(offset);
const nextOffset = offset + text.length;
newRange.setStart(container, nextOffset);
newRange.setEnd(container, nextOffset);
} else {
const textNode = document.createTextNode(text);
container.insertBefore(textNode, container.childNodes[offset]);
newRange.setStart(textNode, text.length);
newRange.setEnd(textNode, text.length);
}
replaceSelection(newRange);
}
/**
* Insert nodes to current selection,
* does not change selection.
*/
export function insertNodes(...nodes) {
const selection = window.getSelection();
const range = selection.getRangeAt(0);
if (!range.collapsed) {
range.deleteContents();
}
const offset = range.startOffset;
const container = range.startContainer;
if (container.nodeName === '#text') {
const startSlice = container.textContent.slice(0, offset);
const endSlice = container.textContent.slice(offset);
if (startSlice) {
nodes.splice(0, 0, document.createTextNode(startSlice));
}
if (endSlice) {
nodes.push(document.createTextNode(endSlice));
}
const parentNode = container.parentNode;
nodes.forEach(n => parentNode.insertBefore(n, container));
parentNode.removeChild(container);
} else {
let parentNode = container;
let nextSibling = container.childNodes[offset];
nodes.forEach(n => parentNode.insertBefore(n, nextSibling));
}
}
/**
* Helper to replace current selection with range.
*/
export function replaceSelection(range) {
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);
}
/**
* Helper to to know if selection is collapsed.
*/
export function isSelectionCollapsed() {
return window.getSelection().isCollapsed;
}
/**
* Helper to get current selection range.
*/
export function getSelectionRange() {
const selection = window.getSelection();
return selection.rangeCount ? selection.getRangeAt(0) : null;
}
// Adds a bogus 'br' at the end of the node if not existant.
export function addBogusBR(node) {
if (!isBlockElement(node)) {
return;
}
if (!node.lastChild || !isBogusBR(node.lastChild)) {
node.appendChild(document.createElement('br'));
}
}
/**
* Returns true if selection is completely inside
* given nodes.
*/
export function isSelectionInside(...nodes) {
let foundStart = false;
const range = getSelectionRange();
if (!range) {
return false;
}
for (let i = 0; i < nodes.length; i++) {
if (!foundStart) {
foundStart = nodeContains(nodes[i], range.startContainer);
}
if (foundStart) {
const foundEnd = nodeContains(nodes[i], range.endContainer);
if (foundEnd) {
return true;
}
}
}
return false;
}
/**
* Insert new line. This is what happens
* when adding new lines through pressing Enter.
* Deals with browers quirks.
*/
export function insertNewLine(changeSelection) {
// Insert <br> node.
const el = document.createElement('br');
insertNodes(el);
// If we are adding to the end of the node, we also need
// to add a bogus br.
if (!el.nextSibling) {
el.parentNode.appendChild(document.createElement('br'));
}
// Adding directly before a block element needs also a bogus br.
if (el.nextSibling && isBlockElement(el.nextSibling)) {
el.parentNode.insertBefore(document.createElement('br'), el.nextSibling);
}
// Calculate next selection.
const range = document.createRange();
if (el.nextSibling) {
const offset = indexOfChildNode(el.parentNode, el.nextSibling);
range.setStart(el.parentNode, offset);
range.setEnd(el.parentNode, offset);
} else {
const offset = el.parentNode.childNodes.length - 1;
range.setStart(el.parentNode, offset);
range.setEnd(el.parentNode, offset);
}
if (changeSelection) {
replaceSelection(range);
}
}
/**
* Inserts a new line after given node.
*/
export function insertNewLineAfterNode(node, changeSelection) {
const el = document.createElement('br');
if (node.nextSibling) {
node.parentNode.insertBefore(el, node.nextSibling);
} else {
node.parentNode.appendChild(el);
}
if (changeSelection) {
const offset = indexOfChildNode(node.parentNode, el);
const range = document.createRange();
range.setStart(node.parentNode, offset);
range.setEnd(node.parentNode, offset);
replaceSelection(range);
}
}
/**
* Given a container and a offset, return the selected
* node. Usually to resolve the start or end of a range.
*/
export function getRangeNode(container, offset) {
if (container.nodeName === '#text') {
return container;
}
return container.childNodes[offset];
}
/**
* Returns an array of all nodes before `node`.
*/
export function getLeftOfNode(node) {
let result = [];
let leftMost = node;
while (
leftMost.previousSibling &&
leftMost.previousSibling.tagName !== 'BR' &&
!isBlockElement(leftMost.previousSibling)
) {
result.splice(0, 0, leftMost.previousSibling);
leftMost = leftMost.previousSibling;
}
return result;
}
export function isBogusBR(node) {
return (
(!node.previousSibling || !isBlockElement(node.previousSibling)) &&
node.tagName === 'BR' &&
(!node.nextSibling || isBlockElement(node.previousSibling))
);
}
/**
* Returns an array of all nodes after `node`.
*/
export function getRightOfNode(node) {
let result = [];
let cur = node;
while (
cur.nextSibling &&
cur.nextSibling.tagName !== 'BR' &&
!isBlockElement(cur.nextSibling)
) {
cur = cur.nextSibling;
result.push(cur);
}
if (
cur.nextSibling &&
cur.nextSibling.tagName === 'BR' &&
!isBogusBR(cur.nextSibling)
) {
result.push(cur.nextSibling);
}
return result;
}
/**
* Given `node` find the line it belongs too
* and return the whole line as an array.
*/
export function getWholeLine(node) {
if (isBlockElement(node)) {
return [node];
}
const child = isBlockElement(node.parentNode)
? node
: lastParentBeforeBlock(node);
if (child.tagName === 'BR') {
return [...getLeftOfNode(child), child];
}
return [...getLeftOfNode(child), child, ...getRightOfNode(child)];
}
/**
* Get selected line at the start of the selection.
* Returns an array of nodes.
*/
export function getSelectedLine() {
const range = getSelectionRange();
if (!range) {
return [];
}
const start = getRangeNode(range.startContainer, range.startOffset);
return start ? getWholeLine(start) : [];
}
/**
* Finds a commen block ancestor in the selection
* and return "whole" lines as an array of nodes.
*/
export function getSelectedNodesExpanded() {
const range = getSelectionRange();
if (!range) {
return [];
}
if (range.collapsed) {
return getSelectedLine();
}
let ancestor = range.commonAncestorContainer;
if (!isBlockElement(ancestor)) {
ancestor = findParentBlock(ancestor);
}
const result = getSelectedChildren(ancestor);
return [
...getLeftOfNode(result[0]),
...result,
...getRightOfNode(result[result.length - 1]),
];
}
/**
* Returns array of children that intersects with
* the selection.
*/
export function getSelectedChildren(ancestor) {
const result = [];
const range = getSelectionRange();
if (!range) {
return result;
}
if (!range) {
return result;
}
const start = getRangeNode(range.startContainer, range.startOffset);
const end = getRangeNode(range.endContainer, range.endOffset);
let foundStart = false;
for (let i = 0; i < ancestor.childNodes.length; i++) {
const node = ancestor.childNodes[i];
if (!foundStart) {
if (nodeContains(node, start)) {
foundStart = true;
}
}
if (foundStart) {
result.push(node);
if (nodeContains(node, end)) {
break;
}
}
}
return result;
}
/**
* Removes node and assimilate its children with the parent.
*/
export function outdentBlock(node, changeSelection) {
// Save previous range.
const selectionWasInside = isSelectionInside(node);
const {
startContainer,
startOffset,
endContainer,
endOffset,
} = getSelectionRange();
// Remove bogus br
if (node.lastChild && node.lastChild.tagName === 'BR') {
node.removeChild(node.lastChild);
}
// A new lines to substitute the missing block element.
const needLineAfter =
node.nextSibling &&
!isBlockElement(node.nextSibling) &&
node.lastChild &&
!isBlockElement(node.lastChild);
const needLineBefore =
node.previousSibling &&
!isBlockElement(node.previousSibling) &&
node.previousSibling.tageName !== 'BR';
const parentNode = node.parentNode;
if (needLineBefore) {
parentNode.insertBefore(document.createElement('BR'), node);
}
const previousOffset = indexOfChildNode(parentNode, node);
while (node.firstChild) {
parentNode.insertBefore(node.firstChild, node);
}
if (needLineAfter) {
parentNode.insertBefore(document.createElement('BR'), node);
}
parentNode.removeChild(node);
if (changeSelection) {
const range = document.createRange();
if (selectionWasInside) {
if (startContainer === node) {
range.setStart(parentNode, startOffset + previousOffset);
} else {
range.setStart(startContainer, startOffset);
}
if (endContainer === node) {
range.setEnd(parentNode, endOffset + previousOffset);
} else {
range.setEnd(endContainer, endOffset);
}
} else {
range.setStart(parentNode, previousOffset);
range.setEnd(parentNode, previousOffset);
}
replaceSelection(range);
}
}
/**
* Indent children.
*/
export function indentNodes(nodes, tagName, changeSelection) {
const parentNode = nodes[0].parentNode;
const node = document.createElement(tagName);
// Remove bogus BR if the blockquote is the last element.
// Otherwise there will be an unwanted empty line.
const lastNode = nodes[nodes.length - 1];
if (
lastNode.nextSibling === parentNode.lastChild &&
isBogusBR(parentNode.lastChild)
) {
parentNode.removeChild(parentNode.lastChild);
}
const firstNode = nodes[0];
// Remove previous br as it is not needed
if (firstNode.previousSibling && firstNode.previousSibling.tagName === 'BR') {
parentNode.removeChild(firstNode.previousSibling);
}
// Finally indent.
parentNode.insertBefore(node, firstNode);
nodes.forEach(n => {
node.appendChild(n);
});
if (changeSelection) {
selectEndOfNode(node);
}
return node;
}
function cloneNodeAndRangeHelper(node, range, rangeCloned) {
const nodeCloned = node.cloneNode(false);
for (let i = 0; i < node.childNodes.length; i++) {
const n = node.childNodes[i];
nodeCloned.appendChild(cloneNodeAndRangeHelper(n, range, rangeCloned));
}
if (range.startContainer === node) {
rangeCloned.setStart(nodeCloned, range.startOffset);
}
if (range.endContainer === node) {
rangeCloned.setEnd(nodeCloned, range.endOffset);
}
return nodeCloned;
}
/**
* Clones node and returns both the cloned node and an equivalent Range.
*/
export function cloneNodeAndRange(node, range) {
const rangeCloned = range.cloneRange();
const nodeCloned = cloneNodeAndRangeHelper(node, range, rangeCloned);
if (
rangeCloned.startContainer === range.startContainer ||
rangeCloned.endContainer === range.endContainer
) {
throw new Error('Range not inside node');
}
return [nodeCloned, rangeCloned];
}
/**
* Take children of the second node and replace children of first node.
*/
export function replaceNodeChildren(node, node2) {
while (node.firstChild) {
node.removeChild(node.firstChild);
}
while (node2.firstChild) {
node.appendChild(node2.firstChild);
}
}
/**
* Tries to select the end of node.
* Currently looks for <br> and text nodes to find a suitable
* candidate for a selection.
*/
export function selectEndOfNode(node) {
for (let i = node.childNodes.length - 1; i >= 0; i--) {
let child = node.childNodes[i];
const s = selectEndOfNode(child);
if (s) {
return true;
}
if (child.tagName === 'BR') {
if (
child.previousSibling &&
child.previousSibling.childName === '#text'
) {
child = child.previousSibling;
} else {
const offset = indexOfChildNode(node, child);
const range = document.createRange();
range.setStart(node, offset);
range.setEnd(node, offset);
replaceSelection(range);
return true;
}
}
if (child.nodeName === '#text') {
const range = document.createRange();
range.setStart(child, child.textContent.length);
range.setEnd(child, child.textContent.length);
replaceSelection(range);
return true;
}
}
return false;
}
@@ -0,0 +1,65 @@
/**
* Simple size limited Undo stack.
*/
export default class Undo {
/**
* undoStack contains all known values.
* The last value of this stack represent
* the most recent change.
*/
undoStack = [];
redoStack = [];
size;
constructor(size = 100) {
this.size = size;
}
clear() {
this.undoStack = [];
this.redoStack = [];
}
canUndo() {
return this.undoStack.length > 1;
}
canRedo() {
return this.redoStack.length;
}
undo() {
if (!this.canUndo()) {
throw new Error('Nothing to undo');
}
const cur = this.undoStack.pop();
this.redoStack.push(cur);
return this.undoStack[this.undoStack.length - 1];
}
redo() {
if (!this.canRedo()) {
throw new Error('Nothing to redo');
}
const x = this.redoStack.pop();
this.undoStack.push(x);
return x;
}
save(x, ...meta) {
// Ignore if we already have that saved.
if (
this.undoStack.length &&
this.undoStack[this.undoStack.length - 1][0] === x
) {
return;
}
this.undoStack.push([x, ...meta]);
// Adhere to maximum size.
if (this.undoStack.length > this.size) {
this.undoStack.splice(0, 1);
}
this.redoStack = [];
}
}
@@ -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 AdminCommentContent from '../components/AdminCommentContent';
export default withFragments({
comment: gql`
fragment TalkPluginRichText_AdminCommentContent_comment on Comment {
body
richTextBody
}
`,
})(AdminCommentContent);
@@ -4,7 +4,7 @@ import CommentContent from '../components/CommentContent';
export default withFragments({
comment: gql`
fragment TalkPluginRTE_CommentContent_comment on Comment {
fragment TalkPluginRichText_CommentContent_comment on Comment {
body
richTextBody
}
@@ -4,7 +4,7 @@ import Editor from '../components/Editor';
export default withFragments({
comment: gql`
fragment TalkPluginRTE_Editor_comment on Comment {
fragment TalkPluginRichText_Editor_comment on Comment {
body
richTextBody
}
@@ -1,24 +1,28 @@
import Editor from './containers/Editor';
import CommentContent from './containers/CommentContent';
import AdminCommentContent from './containers/AdminCommentContent';
import translations from './translations.yml';
import { gql } from 'react-apollo';
export default {
translations,
slots: {
draftArea: [Editor],
commentContent: [CommentContent],
adminCommentContent: [CommentContent],
userDetailCommentContent: [CommentContent],
adminCommentContent: [AdminCommentContent],
userDetailCommentContent: [AdminCommentContent],
},
fragments: {
CreateCommentResponse: gql`
fragment TalkRTE_CreateCommentResponse on CreateCommentResponse {
fragment TalkRichText_CreateCommentResponse on CreateCommentResponse {
comment {
richTextBody
}
}
`,
EditCommentResponse: gql`
fragment TalkRTE_EditCommentResponse on EditCommentResponse {
fragment TalkRichText_EditCommentResponse on EditCommentResponse {
comment {
richTextBody
}
@@ -48,7 +52,7 @@ export default {
},
update: proxy => {
const editCommentFragment = gql`
fragment Talk_EditComment on Comment {
fragment TalkRichText_EditComment on Comment {
richTextBody
}
`;
@@ -0,0 +1,6 @@
en:
talk-plugin-rich-text:
format_bold: bold
format_italic: italic
format_blockquote: blockquote
@@ -0,0 +1,16 @@
export function htmlNormalizer(htmlInput) {
let str = htmlInput;
// Some tags have not been normalized across browsers in `Coral RTE` yet.
// So we'll do this manual step here for now.
// 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;
}
+2 -1
View File
@@ -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', 'span'],
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,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"
}
+1 -1
View File
@@ -12,7 +12,7 @@ plugin:
Using the [Perspective API](http://perspectiveapi.com/), this
plugin will warn users and reject comments that exceed the predefined toxicity
threshold. For more information on what Toxic Comments are, check out the
[Toxic Comments](./toxic-comments/) documentation.
[Toxic Comments](/talk/toxic-comments/) documentation.
Configuration:
@@ -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],
},
};
+2
View File
@@ -0,0 +1,2 @@
const { getReactionConfig } = require('../../plugin-api/beta/server');
module.exports = getReactionConfig('upvote');
+9
View File
@@ -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"
}