diff --git a/.eslintignore b/.eslintignore index afa7bf16d..b58e4f058 100644 --- a/.eslintignore +++ b/.eslintignore @@ -13,6 +13,5 @@ plugins/* !plugins/coral-plugin-viewing-options !plugins/coral-plugin-comment-content !plugins/talk-plugin-permalink -!plugins/talk-plugin-featured-comments - +!plugins/talk-plugin-featured node_modules diff --git a/.gitignore b/.gitignore index bd8edcd94..0e809b43a 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,6 @@ plugins/* !plugins/coral-plugin-viewing-options !plugins/coral-plugin-comment-content !plugins/talk-plugin-permalink -!plugins/talk-plugin-featured-comments +!plugins/talk-plugin-featured **/node_modules/* diff --git a/bin/cli b/bin/cli index 33560c53b..52d67c9af 100755 --- a/bin/cli +++ b/bin/cli @@ -9,13 +9,17 @@ const program = require('./commander'); program .command('serve', 'serve the application') + .command('settings', 'interact with the application settings') .command('assets', 'interact with assets') .command('setup', 'setup the application') .command('jobs', 'work with the job queues') .command('token', 'work with the access tokens') .command('users', 'work with the application auth') .command('migration', 'provides utilities for migrating the database') - .command('plugins', 'provides utilities for interacting with the plugin system') + .command( + 'plugins', + 'provides utilities for interacting with the plugin system' + ) .parse(process.argv); /** @@ -25,7 +29,10 @@ program * labled with the PID written out by the parent process. */ process.once('exit', () => { - if ((program.runningCommand.killed === false) && (program.runningCommand.exitCode === null)) { + if ( + program.runningCommand.killed === false && + program.runningCommand.exitCode === null + ) { program.runningCommand.kill('SIGINT'); } }); diff --git a/bin/cli-plugins b/bin/cli-plugins index b1dee381b..942036d29 100755 --- a/bin/cli-plugins +++ b/bin/cli-plugins @@ -8,13 +8,14 @@ // https://yarnpkg.com/ const program = require('./commander'); +const inquirer = require('inquirer'); // Make things colorful! require('colors'); const emoji = require('node-emoji'); const dir = process.cwd(); -const fs = require('fs'); +const fs = require('fs-extra'); const path = require('path'); const spawn = require('cross-spawn'); const semver = require('semver'); @@ -272,10 +273,128 @@ async function reconcilePluginDeps({skipLocal, skipRemote, dryRun, upgradeRemote console.log(`✨ Done in ${totalTime}s.`); } +async function createSeedPlugin() { + const pluginsDir = path.join(__dirname, 'plugins'); + + function pluginNameExists(pluginName) { + const pluginNames = fs.readdirSync(pluginsDir); + return !!pluginNames + .filter((pn) => pn === pluginName).length; + } + + let answers = await inquirer.prompt([ + { + type: 'input', + name: 'pluginName', + message: 'Plugin Name:', + validate: (input) => { + + if (pluginNameExists(input)) { + return 'Please, choose another name. That name already exists'; + } + + if (input && input.length > 0) { + return true; + } + + return 'Plugin Name is required.'; + } + }, + { + type: 'confirm', + name: 'server', + message: 'Is this plugin extending the server capabilities?' + }, + { + type: 'confirm', + name: 'client', + message: 'Is this plugin extending the client capabilities?' + }, + { + type: 'confirm', + name: 'addPluginsJson', + message: 'Should we add it to the plugins.json?' + } + ]); + + //============================================================================== + // Creating plugin seed + //============================================================================== + + const seedPlugin = path.join(__dirname, 'bin/templates/plugin'); + const newPluginPath = path.join(pluginsDir, answers.pluginName); + + if (fs.existsSync(seedPlugin)) { + + if (answers.server && answers.client) { + + // This is a server-side and client-side plugin!, let's copy the template + fs.copySync(seedPlugin, newPluginPath); + } else { + + fs.copySync(seedPlugin, newPluginPath, {filter: (p) => { + + // Allowing plugin folder and files with no subfolders + const rootRx = /plugin$|plugin\/[^/]*(\.).{2,3}/igm; + if (rootRx.test(p) && (fs.lstatSync(p).isDirectory() || fs.lstatSync(p).isFile())) { + return true; + } + + // If it's a client-side plugin, copying client folder + if (answers.client) { + return /client/.test(p); + } + + // If it's a server-side plugin, copying server folder + if (answers.server) { + return /server/.test(p); + } + + }}); + } + + // Let's add this to the plugins.json + if (answers.addPluginsJson) { + const pluginsJson = path.join(dir, 'plugins.json'); + + fs.readJson(pluginsJson) + .then((j) => { + + // This is a client-side plugin, let's push this. + if (answers.client) { + j.client.push(answers.pluginName); + + const output = JSON.stringify(j, null, 2); + fs.writeFileSync(pluginsJson, output); + } + + // This is a server-side plugin, let's push this. + if (answers.server) { + j.server.push(answers.pluginName); + + const output = JSON.stringify(j, null, 2); + fs.writeFileSync(pluginsJson, output); + } + }) + .catch((err) => { + console.error(err); + }); + } + + console.log(`✨ Yay! Plugin created! Find your plugin: ${answers.pluginName} in the ./plugins folder`); + } + +} + //============================================================================== // Setting up the program command line arguments. //============================================================================== +program + .command('create') + .description('creates a seed plugin') + .action(createSeedPlugin); + program .command('list') .description('') diff --git a/bin/cli-settings b/bin/cli-settings new file mode 100755 index 000000000..f8768046c --- /dev/null +++ b/bin/cli-settings @@ -0,0 +1,59 @@ +#!/usr/bin/env node + +const program = require('./commander'); +const inquirer = require('inquirer'); +const mongoose = require('../services/mongoose'); +const SettingsService = require('../services/settings'); +const util = require('./util'); + +// Register the shutdown criteria. +util.onshutdown([() => mongoose.disconnect()]); + +/** + * Change the organization name + */ +async function changeOrgName() { + try { + let settings = await SettingsService.retrieve(); + + let {organizationName} = await inquirer.prompt([ + { + name: 'organizationName', + message: 'Organization Name', + default: settings.organizationName + } + ]); + + if (settings.organizationName !== organizationName) { + settings.organizationName = organizationName; + + await SettingsService.update(settings); + + console.log('Settings were updated.'); + } else { + console.log('No update needed, no change was made.'); + } + } catch (err) { + console.error(err); + util.shutdown(1); + } + + util.shutdown(); +} + +//============================================================================== +// Setting up the program command line arguments. +//============================================================================== + +program + .command('change-org-name') + .description('change the organization name') + .action(changeOrgName); + +program.parse(process.argv); + +// If there is no command listed, output help. +if (!process.argv.slice(2).length) { + program.outputHelp(); + util.shutdown(); +} diff --git a/bin/templates/plugin/client/.babelrc b/bin/templates/plugin/client/.babelrc new file mode 100644 index 000000000..60be246eb --- /dev/null +++ b/bin/templates/plugin/client/.babelrc @@ -0,0 +1,14 @@ +{ + "presets": [ + "es2015" + ], + "plugins": [ + "add-module-exports", + "transform-class-properties", + "transform-decorators-legacy", + "transform-object-assign", + "transform-object-rest-spread", + "transform-async-to-generator", + "transform-react-jsx" + ] +} \ No newline at end of file diff --git a/bin/templates/plugin/client/.eslintrc.json b/bin/templates/plugin/client/.eslintrc.json new file mode 100644 index 000000000..9fe56bd14 --- /dev/null +++ b/bin/templates/plugin/client/.eslintrc.json @@ -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"] }] + } +} diff --git a/bin/templates/plugin/client/components/MyPluginComponent.css b/bin/templates/plugin/client/components/MyPluginComponent.css new file mode 100644 index 000000000..187e68750 --- /dev/null +++ b/bin/templates/plugin/client/components/MyPluginComponent.css @@ -0,0 +1,27 @@ +.myPluginContainer { + padding: 10px; + background: #f0f0f0; + border: 1px solid #d6d6d6; + margin: 10px 0; + text-align: center; + border-radius: 3px; +} + +.logo { + position: block; + animation: spin 2s infinite ease; + animation-delay: 1s; +} + +@keyframes spin { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} + +.description { + color: #444444; +} diff --git a/bin/templates/plugin/client/components/MyPluginComponent.js b/bin/templates/plugin/client/components/MyPluginComponent.js new file mode 100644 index 000000000..89e274e1a --- /dev/null +++ b/bin/templates/plugin/client/components/MyPluginComponent.js @@ -0,0 +1,25 @@ +import React from 'react'; +import {CoralLogo} from 'plugin-api/beta/client/components/ui'; +import styles from './MyPluginComponent.css'; + +class MyPluginComponent extends React.Component { + render() { + return ( +
+ +
+

Plugin created by Talk CLI

+ + + To read more about plugins check{' '} + + our docs and guides! + + +
+
+ ); + } +} + +export default MyPluginComponent; diff --git a/bin/templates/plugin/client/index.js b/bin/templates/plugin/client/index.js new file mode 100644 index 000000000..acd0910be --- /dev/null +++ b/bin/templates/plugin/client/index.js @@ -0,0 +1,26 @@ + +/** + This is a client index example file and it could look like this: + + ``` + import LoveButton from './components/LoveButton'; + + export default { + slots: { + commentReactions: [LoveButton] + }, + reducer, + translations + }; + ``` + + To read more info on how to build client plugins. Please, go to: https://coralproject.github.io/talk/plugins-client.html + */ + +import MyPluginComponent from './components/MyPluginComponent'; + +export default { + slots: { + stream: [MyPluginComponent] + } +}; diff --git a/bin/templates/plugin/client/translations.yml b/bin/templates/plugin/client/translations.yml new file mode 100644 index 000000000..d8407b801 --- /dev/null +++ b/bin/templates/plugin/client/translations.yml @@ -0,0 +1,15 @@ +# +# This file is for translations and they should look like this: +# +# +# ``` +# en: +# coral-plugin-respect: +# respect: Respect +# respected: Respected +# es: +# coral-plugin-respect: +# respect: Respetar +# respected: Respetado +# ``` +# diff --git a/bin/templates/plugin/index.js b/bin/templates/plugin/index.js new file mode 100644 index 000000000..f053ebf79 --- /dev/null +++ b/bin/templates/plugin/index.js @@ -0,0 +1 @@ +module.exports = {}; diff --git a/client/coral-embed-stream/src/actions/stream.js b/client/coral-embed-stream/src/actions/stream.js index 9b5d94a9b..d936879e9 100644 --- a/client/coral-embed-stream/src/actions/stream.js +++ b/client/coral-embed-stream/src/actions/stream.js @@ -1,4 +1,4 @@ -import {pym} from 'coral-framework'; +import pym from 'coral-framework/services/pym'; import * as actions from '../constants/stream'; export const setActiveReplyBox = (id) => ({type: actions.SET_ACTIVE_REPLY_BOX, id}); @@ -47,3 +47,7 @@ export const removeCommentClassName = (idx) => ({ type: actions.REMOVE_COMMENT_CLASSNAME, idx }); + +export const setActiveTab = (tab) => (dispatch) => { + dispatch({type: actions.SET_ACTIVE_TAB, tab}); +}; diff --git a/client/coral-embed-stream/src/components/AllCommentsPane.js b/client/coral-embed-stream/src/components/AllCommentsPane.js new file mode 100644 index 000000000..6af72475a --- /dev/null +++ b/client/coral-embed-stream/src/components/AllCommentsPane.js @@ -0,0 +1,202 @@ +import React from 'react'; + +import LoadMore from './LoadMore'; +import IgnoredCommentTombstone from './IgnoredCommentTombstone'; +import NewCount from './NewCount'; +import {TransitionGroup} from 'react-transition-group'; +import {forEachError} from 'coral-framework/utils'; +import Comment from '../components/Comment'; + +const hasComment = (nodes, id) => nodes.some((node) => node.id === id); + +// resetCursors will return the id cursors of the first and second comment of +// the current comment list. The cursors are used to dertermine which +// comments to show. The spare cursor functions as a backup in case one +// of the comments gets deleted. +function resetCursors(state, props) { + const comments = props.root.asset.comments; + if (comments && comments.nodes.length) { + const idCursors = [comments.nodes[0].id]; + if (comments.nodes[1]) { + idCursors.push(comments.nodes[1].id); + } + return {idCursors}; + } + return {idCursors: []}; +} + +// invalidateCursor is called whenever a comment is removed which is referenced +// by one of the 2 id cursors. It returns a new set of id cursors calculated +// using the help of the backup cursor. +function invalidateCursor(invalidated, state, props) { + const alt = invalidated === 1 ? 0 : 1; + const comments = props.root.asset.comments; + const idCursors = []; + if (state.idCursors[alt]) { + idCursors.push(state.idCursors[alt]); + const index = comments.nodes.findIndex((node) => node.id === idCursors[0]); + const nextInLine = comments.nodes[index + 1]; + if (nextInLine) { + idCursors.push(nextInLine.id); + } + } + return {idCursors}; +} + +class AllCommentsPane extends React.Component { + + constructor(props) { + super(props); + this.state = { + ...resetCursors(this.state, props), + loadingState: '', + }; + } + + componentWillReceiveProps(next) { + const {comments: prevComments} = this.props; + const {comments: nextComments} = next; + + if (!prevComments && nextComments) { + this.setState(resetCursors); + return; + } + + if ( + prevComments && nextComments && + nextComments.nodes.length < prevComments.nodes.length + ) { + + // Invalidate first cursor if referenced comment was removed. + if (this.state.idCursors[0] && !hasComment(nextComments.nodes, this.state.idCursors[0])) { + this.setState(invalidateCursor(0, this.state, next)); + } + + // Invalidate second cursor if referenced comment was removed. + if (this.state.idCursors[1] && !hasComment(nextComments.nodes, this.state.idCursors[1])) { + this.setState(invalidateCursor(1, this.state, next)); + } + } + } + + loadMore = () => { + this.setState({loadingState: 'loading'}); + this.props.loadMore() + .then(() => { + this.setState({loadingState: 'success'}); + }) + .catch((error) => { + this.setState({loadingState: 'error'}); + forEachError(error, ({msg}) => {this.props.addNotification('error', msg);}); + }); + } + + viewNewComments = () => { + this.setState(resetCursors); + }; + + // getVisibileComments returns a list containing comments + // which were authored by current user or comes after the `idCursor`. + getVisibleComments() { + const {comments, currentUser: user} = this.props; + const idCursor = this.state.idCursors[0]; + const userId = user ? user.id : null; + + if (!comments) { + return []; + } + + const view = []; + let pastCursor = false; + comments.nodes.forEach((comment) => { + if (comment.id === idCursor) { + pastCursor = true; + } + if (pastCursor || comment.user.id === userId) { + view.push(comment); + } + }); + return view; + } + + render() { + const { + data, + root, + comments, + commentClassNames, + addTag, + removeTag, + ignoreUser, + setActiveReplyBox, + activeReplyBox, + addNotification, + disableReply, + postComment, + asset, + currentUser, + postFlag, + postDontAgree, + loadNewReplies, + deleteAction, + showSignInDialog, + commentIsIgnored, + charCountEnable, + maxCharCount, + editComment, + } = this.props; + + const {loadingState} = this.state; + const view = this.getVisibleComments(); + + return ( +
+ + + {view.map((comment) => { + return commentIsIgnored(comment) + ? + : ; + })} + + +
+ ); + } +} + +export default AllCommentsPane; diff --git a/client/coral-embed-stream/src/components/Comment.css b/client/coral-embed-stream/src/components/Comment.css index 4c8a9fc86..d397185dc 100644 --- a/client/coral-embed-stream/src/components/Comment.css +++ b/client/coral-embed-stream/src/components/Comment.css @@ -1,8 +1,17 @@ .root { - margin-top: 16px; margin-left: 20px; - margin-bottom: 15px; + margin-bottom: 16px; position: relative; + border-top: 1px solid rgba(0, 0, 0, 0.1); + padding-top: 12px; +} + +.rootLevel0:first-child { + padding-top: 0; +} + +.root:first-child { + border: 0; } .rootLevel0 { @@ -52,6 +61,13 @@ font-style: italic; } +.hr { + border: 0; + height: 0; + border-top: 1px solid rgba(0, 0, 0, 0.1); + border-bottom: 1px solid rgba(255, 255, 255, 0.3); +} + /* element in the top right of the Comment */ .topRight { float: right; diff --git a/client/coral-embed-stream/src/components/Comment.js b/client/coral-embed-stream/src/components/Comment.js index f531d794f..8a728a0d5 100644 --- a/client/coral-embed-stream/src/components/Comment.js +++ b/client/coral-embed-stream/src/components/Comment.js @@ -128,7 +128,6 @@ export default class Comment extends React.Component { } static propTypes = { - reactKey: PropTypes.string.isRequired, // id of currently opened ReplyBox. tracked in Stream.js activeReplyBox: PropTypes.string.isRequired, @@ -142,12 +141,8 @@ export default class Comment extends React.Component { addNotification: PropTypes.func.isRequired, postComment: PropTypes.func.isRequired, depth: PropTypes.number.isRequired, - liveUpdates: PropTypes.bool.isRequired, - asset: PropTypes.shape({ - id: PropTypes.string, - title: PropTypes.string, - url: PropTypes.string - }).isRequired, + liveUpdates: PropTypes.bool, + asset: PropTypes.object.isRequired, currentUser: PropTypes.shape({ id: PropTypes.string.isRequired }), @@ -321,7 +316,6 @@ export default class Comment extends React.Component { const view = this.getVisibileReplies(); const {loadingState} = this.state; - const isReply = !!parentId; const isPending = comment.id.indexOf('pending') >= 0; const isHighlighted = highlighted === comment.id; @@ -358,7 +352,7 @@ export default class Comment extends React.Component { return false; }); - const rootClassNames = [ + const rootClassName = cn( 'talk-stream-comment-wrapper', `talk-stream-comment-wrapper-level-${depth}`, styles.root, @@ -367,28 +361,27 @@ export default class Comment extends React.Component { ...conditionalClassNames, [styles.enter]: this.state.animateEnter, }, - ]; + ); + + const commentClassName = cn( + 'talk-stream-comment', + `talk-stream-comment-level-${depth}`, + styles.comment, + styles[`commentLevel${depth}`], + { + [styles.pendingComment]: isPending, + [styles.highlightedComment]: isHighlighted, + 'talk-stream-pending-comment': isPending, + 'talk-stream-highlighted-comment': isHighlighted, + } + ); return (
- {!isReply &&
} -
+
{isStaff(comment.tags) ? Staff : null} @@ -439,9 +432,9 @@ export default class Comment extends React.Component { ? ; diff --git a/client/coral-embed-stream/src/components/EditableCommentContent.js b/client/coral-embed-stream/src/components/EditableCommentContent.js index 7e6035d58..06d8e6b9d 100644 --- a/client/coral-embed-stream/src/components/EditableCommentContent.js +++ b/client/coral-embed-stream/src/components/EditableCommentContent.js @@ -18,11 +18,6 @@ export class EditableCommentContent extends React.Component { // show notification to the user (e.g. for errors) addNotification: PropTypes.func.isRequired, - asset: PropTypes.shape({ - settings: PropTypes.shape({ - charCountEnable: PropTypes.bool, - }), - }).isRequired, // comment that is being edited comment: PropTypes.shape({ @@ -39,6 +34,7 @@ export class EditableCommentContent extends React.Component { currentUser: PropTypes.shape({ id: PropTypes.string.isRequired }), + charCountEnable: PropTypes.bool, maxCharCount: PropTypes.number, // edit a comment, passed {{ body }} @@ -121,7 +117,7 @@ export class EditableCommentContent extends React.Component {
{ - switch (tab) { - case 0: - this.props.setActiveTab('stream'); - break; - case 1: - this.props.setActiveTab('profile'); - // TODO: move data fetching to profile container. + // TODO: move data fetching to appropiate containers. + switch (tab) { + case 'profile': this.props.data.refetch(); break; - case 2: - this.props.setActiveTab('config'); - - // TODO: move data fetching to config container. + case 'config': this.props.data.refetch(); break; } + this.props.setActiveTab(tab); }; - handleShowProfile = () => this.props.setActiveTab('profile'); - render() { - const {activeTab, viewAllComments, commentId} = this.props; - const {asset: {totalCommentCount}} = this.props.root; + const {activeTab, commentId} = this.props; const {user} = this.props.auth; + const hasHighlightedComment = !!commentId; return ( -
-
- - - {t('framework.my_profile')} - {t('framework.configure_stream')} - - {commentId && - } - - +
+ + + {t('embed_comments_tab')} + + + {t('framework.my_profile')} + + {can(user, 'UPDATE_CONFIG') && + + {t('framework.configure_stream')} + + } + + + + + - - + + - - + + - -
+ +
); } diff --git a/client/coral-embed-stream/src/components/NewCount.js b/client/coral-embed-stream/src/components/NewCount.js index 8f3b77dcb..328c76481 100644 --- a/client/coral-embed-stream/src/components/NewCount.js +++ b/client/coral-embed-stream/src/components/NewCount.js @@ -1,4 +1,5 @@ import React, {PropTypes} from 'react'; +import {Button} from 'coral-ui'; import t from 'coral-framework/services/i18n'; @@ -6,11 +7,11 @@ const NewCount = ({count, loadMore}) => { return
{ count ? - + : null }
; diff --git a/client/coral-embed-stream/src/components/Stream.css b/client/coral-embed-stream/src/components/Stream.css new file mode 100644 index 000000000..06f1efd1f --- /dev/null +++ b/client/coral-embed-stream/src/components/Stream.css @@ -0,0 +1,26 @@ +.root { + margin-top: 6px; +} + +.viewAllButton { + position: absolute; + right: 0px; +} + +.tabPanel { + margin-top: 8px; +} + +.filterWrapper { + position: absolute; + right: 0; + margin-top: 6px; +} + +.highlightedContainer { + margin-top: 28px; +} + +.tabContainer { + margin-top: 28px; +} diff --git a/client/coral-embed-stream/src/components/Stream.js b/client/coral-embed-stream/src/components/Stream.js index c0921a902..3fe4659da 100644 --- a/client/coral-embed-stream/src/components/Stream.js +++ b/client/coral-embed-stream/src/components/Stream.js @@ -1,5 +1,5 @@ -import React, {PropTypes} from 'react'; -import LoadMore from './LoadMore'; +import React from 'react'; +import PropTypes from 'prop-types'; import {StreamError} from './StreamError'; import Comment from '../components/Comment'; import SuspendedAccount from './SuspendedAccount'; @@ -10,161 +10,74 @@ import {ModerationLink} from 'coral-plugin-moderation'; import RestrictedMessageBox from 'coral-framework/components/RestrictedMessageBox'; import t, {timeago} from 'coral-framework/services/i18n'; +import {getSlotComponents} from 'coral-framework/helpers/plugins'; import CommentBox from 'coral-plugin-commentbox/CommentBox'; import QuestionBox from 'coral-plugin-questionbox/QuestionBox'; -import IgnoredCommentTombstone from './IgnoredCommentTombstone'; -import NewCount from './NewCount'; -import {TransitionGroup} from 'react-transition-group'; -import {forEachError} from 'coral-framework/utils'; +import {Button, TabBar, Tab, TabCount, TabContent, TabPane} from 'coral-ui'; +import cn from 'classnames'; + import {getTopLevelParent} from '../graphql/utils'; +import AllCommentsPane from './AllCommentsPane'; -const hasComment = (nodes, id) => nodes.some((node) => node.id === id); - -// resetCursors will return the id cursors of the first and second comment of -// the current comment list. The cursors are used to dertermine which -// comments to show. The spare cursor functions as a backup in case one -// of the comments gets deleted. -function resetCursors(state, props) { - const comments = props.root.asset.comments; - if (comments && comments.nodes.length) { - const idCursors = [comments.nodes[0].id]; - if (comments.nodes[1]) { - idCursors.push(comments.nodes[1].id); - } - return {idCursors}; - } - return {idCursors: []}; -} - -// invalidateCursor is called whenever a comment is removed which is referenced -// by one of the 2 id cursors. It returns a new set of id cursors calculated -// using the help of the backup cursor. -function invalidateCursor(invalidated, state, props) { - const alt = invalidated === 1 ? 0 : 1; - const comments = props.root.asset.comments; - const idCursors = []; - if (state.idCursors[alt]) { - idCursors.push(state.idCursors[alt]); - const index = comments.nodes.findIndex((node) => node.id === idCursors[0]); - const nextInLine = comments.nodes[index + 1]; - if (nextInLine) { - idCursors.push(nextInLine.id); - } - } - return {idCursors}; -} +import styles from './Stream.css'; class Stream extends React.Component { constructor(props) { super(props); this.state = { - ...resetCursors(this.state, props), keepCommentBox: false, - loadingState: '', }; } componentWillReceiveProps(next) { - const {root: {asset: {comments: prevComments}}} = this.props; - const {root: {asset: {comments: nextComments}}} = next; - - if (!prevComments && nextComments) { - this.setState(resetCursors); - return; - } // Keep comment box when user was live suspended, banned, ... if (!this.userIsDegraged(this.props) && this.userIsDegraged(next)) { this.setState({keepCommentBox: true}); } - - if ( - prevComments && nextComments && - nextComments.nodes.length < prevComments.nodes.length - ) { - - // Invalidate first cursor if referenced comment was removed. - if (this.state.idCursors[0] && !hasComment(nextComments.nodes, this.state.idCursors[0])) { - this.setState(invalidateCursor(0, this.state, next)); - } - - // Invalidate second cursor if referenced comment was removed. - if (this.state.idCursors[1] && !hasComment(nextComments.nodes, this.state.idCursors[1])) { - this.setState(invalidateCursor(1, this.state, next)); - } - } } - viewNewComments = () => { - this.setState(resetCursors); - }; - - setActiveReplyBox = (reactKey) => { + setActiveReplyBox = (id) => { if (!this.props.auth.user) { this.props.showSignInDialog(); } else { - this.props.setActiveReplyBox(reactKey); + this.props.setActiveReplyBox(id); } }; - loadMoreComments = () => { - this.setState({loadingState: 'loading'}); - this.props.loadMoreComments() - .then(() => { - this.setState({loadingState: 'success'}); - }) - .catch((error) => { - this.setState({loadingState: 'error'}); - forEachError(error, ({msg}) => {this.props.addNotification('error', msg);}); - }); - } - - // getVisibileComments returns a list containing comments - // which were authored by current user or comes after the `idCursor`. - getVisibleComments() { - const {root: {asset: {comments}}, auth: {user}} = this.props; - const idCursor = this.state.idCursors[0]; - const userId = user ? user.id : null; - - if (!comments) { - return []; - } - - const view = []; - let pastCursor = false; - comments.nodes.forEach((comment) => { - if (comment.id === idCursor) { - pastCursor = true; - } - if (pastCursor || comment.user.id === userId) { - view.push(comment); - } - }); - return view; - } - userIsDegraged({auth: {user}} = this.props) { return !can(user, 'INTERACT_WITH_COMMUNITY'); } render() { const { + data, + root, + activeReplyBox, + setActiveReplyBox, + appendItemArray, commentClassNames, - root: {asset, asset: {comments}, comment, me}, + root: {asset, asset: {comments, totalCommentCount}, comment, me}, postComment, addNotification, + editComment, postFlag, postDontAgree, deleteAction, showSignInDialog, + updateItem, + addTag, ignoreUser, + activeStreamTab, + setActiveStreamTab, + loadNewReplies, + loadMoreComments, + viewAllComments, auth: {loggedIn, user}, - pluginProps, editName } = this.props; - const {keepCommentBox, loadingState} = this.state; - const view = this.getVisibleComments(); + const {keepCommentBox} = this.state; const open = asset.closedAt === null; // even though the permalinked comment is the highlighted one, we're displaying its parent + replies @@ -192,7 +105,15 @@ class Stream extends React.Component { } return ( -
+
+ {comment && + } {open ?
@@ -209,7 +130,7 @@ class Stream extends React.Component { {t( 'stream.temporarily_suspended', - this.props.root.settings.organizationName, + root.settings.organizationName, timeago(user.suspension.until) )} } @@ -221,10 +142,10 @@ class Stream extends React.Component { />} {showCommentBox && :

{asset.settings.closedMessage}

} - )} -
- -
- {/* the highlightedComment is isolated after the user followed a permalink */} {highlightedComment - ? - :
- + - - {view.map((comment) => { - return commentIsIgnored(comment) - ? - : ; - })} - - -
} +
+ ) + :
+
+ +
+ + {getSlotComponents('streamTabs').map((PluginComponent) => ( + + + + ))} + + All Comments {totalCommentCount} + + + + {getSlotComponents('streamTabPanes').map((PluginComponent) => ( + + + + ))} + + + + +
+ }
); } @@ -336,12 +272,6 @@ Stream.propTypes = { addNotification: PropTypes.func.isRequired, postComment: PropTypes.func.isRequired, - // dispatch action to add a tag to a comment - addTag: PropTypes.func, - - // dispatch action to remove a tag from a comment - removeTag: PropTypes.func, - // dispatch action to ignore another user ignoreUser: React.PropTypes.func, diff --git a/client/coral-embed-stream/src/constants/stream.js b/client/coral-embed-stream/src/constants/stream.js index 992767455..622a86c18 100644 --- a/client/coral-embed-stream/src/constants/stream.js +++ b/client/coral-embed-stream/src/constants/stream.js @@ -4,3 +4,4 @@ export const VIEW_ALL_COMMENTS = 'VIEW_ALL_COMMENTS'; export const ADD_COMMENT_CLASSNAME = 'ADD_COMMENT_CLASSNAME'; export const REMOVE_COMMENT_CLASSNAME = 'REMOVE_COMMENT_CLASSNAME'; export const THREADING_LEVEL = process.env.TALK_THREADING_LEVEL; +export const SET_ACTIVE_TAB = 'CORAL_STREAM_SET_ACTIVE_TAB'; diff --git a/client/coral-embed-stream/src/containers/Embed.js b/client/coral-embed-stream/src/containers/Embed.js index bf05caf29..a5c6ba1a6 100644 --- a/client/coral-embed-stream/src/containers/Embed.js +++ b/client/coral-embed-stream/src/containers/Embed.js @@ -7,7 +7,9 @@ import branch from 'recompose/branch'; import renderComponent from 'recompose/renderComponent'; import {Spinner} from 'coral-ui'; -import {authActions, assetActions, pym} from 'coral-framework'; +import * as authActions from 'coral-framework/actions/auth'; +import * as assetActions from 'coral-framework/actions/asset'; +import pym from 'coral-framework/services/pym'; import {getDefinitionName} from 'coral-framework/utils'; import {withQuery} from 'coral-framework/hocs'; import Embed from '../components/Embed'; @@ -16,7 +18,6 @@ import {addNotification} from 'coral-framework/actions/notification'; import t from 'coral-framework/services/i18n'; import {setActiveTab} from '../actions/embed'; -import {viewAllComments} from '../actions/stream'; const {logout, checkLogin} = authActions; const {fetchAssetSuccess} = assetActions; @@ -146,9 +147,6 @@ const USERNAME_REJECTED_SUBSCRIPTION = gql` const EMBED_QUERY = gql` query CoralEmbedStream_Embed($assetId: ID, $assetUrl: String, $commentId: ID!, $hasComment: Boolean!, $excludeIgnored: Boolean) { - asset(id: $assetId, url: $assetUrl) { - totalCommentCount(excludeIgnored: $excludeIgnored) - } me { id status @@ -185,7 +183,6 @@ const mapDispatchToProps = (dispatch) => logout, checkLogin, setActiveTab, - viewAllComments, fetchAssetSuccess, addNotification, }, diff --git a/client/coral-embed-stream/src/containers/Stream.js b/client/coral-embed-stream/src/containers/Stream.js index f5ce991c0..30e9d8fe0 100644 --- a/client/coral-embed-stream/src/containers/Stream.js +++ b/client/coral-embed-stream/src/containers/Stream.js @@ -8,12 +8,14 @@ import { withDeleteAction, withIgnoreUser, withEditComment } from 'coral-framework/graphql/mutations'; -import {notificationActions, authActions} from 'coral-framework'; +import * as authActions from 'coral-framework/actions/auth'; +import * as notificationActions from 'coral-framework/actions/notification'; import {editName} from 'coral-framework/actions/user'; -import {setActiveReplyBox} from '../actions/stream'; +import {setActiveReplyBox, setActiveTab, viewAllComments} from '../actions/stream'; import Stream from '../components/Stream'; import Comment from './Comment'; import {withFragments} from 'coral-framework/hocs'; +import {getSlotsFragments} from 'coral-framework/helpers/plugins'; import {Spinner} from 'coral-ui'; import {getDefinitionName} from 'coral-framework/utils'; import { @@ -21,6 +23,7 @@ import { insertCommentIntoEmbedQuery, removeCommentFromEmbedQuery, insertFetchedCommentsIntoEmbedQuery, + nest, } from '../graphql/utils'; const {showSignInDialog} = authActions; @@ -151,18 +154,6 @@ class StreamContainer extends React.Component { } } -const nest = (def, level) => { - let result = ''; - for (let x = 0; x < level; x++) { - if (x === 0) { - result += def; - continue; - } - result = result.replace('...nest', def); - } - return result.replace('...nest', ''); -}; - const commentFragment = gql` fragment CoralEmbedStream_Stream_comment on Comment { id @@ -240,6 +231,11 @@ const LOAD_MORE_QUERY = gql` ${Comment.fragments.comment} `; +const pluginFragments = getSlotsFragments([ + 'streamTabs', + 'streamTabPanes', +]); + const fragments = { root: gql` fragment CoralEmbedStream_Stream_root on RootQuery { @@ -281,6 +277,7 @@ const fragments = { startCursor endCursor } + ${pluginFragments.spreads('asset')} } me { status @@ -291,8 +288,11 @@ const fragments = { settings { organizationName } + ${pluginFragments.spreads('root')} ...${getDefinitionName(Comment.fragments.root)} } + ${pluginFragments.definitions('asset')} + ${pluginFragments.definitions('root')} ${Comment.fragments.root} ${commentFragment} `, @@ -308,6 +308,8 @@ const mapStateToProps = (state) => ({ assetUrl: state.stream.assetUrl, activeTab: state.embed.activeTab, previousTab: state.embed.previousTab, + activeStreamTab: state.stream.activeTab, + previousStreamTab: state.stream.previousTab, commentClassNames: state.stream.commentClassNames }); @@ -317,6 +319,8 @@ const mapDispatchToProps = (dispatch) => addNotification, setActiveReplyBox, editName, + viewAllComments, + setActiveStreamTab: setActiveTab, }, dispatch); export default compose( diff --git a/client/coral-embed-stream/src/graphql/utils.js b/client/coral-embed-stream/src/graphql/utils.js index 518f4b62f..523cc571f 100644 --- a/client/coral-embed-stream/src/graphql/utils.js +++ b/client/coral-embed-stream/src/graphql/utils.js @@ -1,16 +1,22 @@ import update from 'immutability-helper'; -import {THREADING_LEVEL} from '../constants/stream'; +function determineCommentDepth(comment) { + let depth = 0; + let cur = comment; + while (cur.parent) { + cur = cur.parent; + depth++; + } + return depth; +} function applyToCommentsOrigin(root, callback) { if (root.comment) { let comment = root.comment; - console.log(comment); - for (let depth = 0; depth <= THREADING_LEVEL; depth++) { + for (let depth = 0; depth <= determineCommentDepth(comment); depth++) { let changes = {$apply: (node) => node ? callback(node) : node}; for (let i = 0; i < depth; i++) { changes = {parent: changes}; } - console.log(changes); comment = update(comment, changes); } @@ -183,3 +189,33 @@ function findAndInsertFetchedComments(parent, comments, parent_id) { export function insertFetchedCommentsIntoEmbedQuery(root, comments, parent_id) { return applyToCommentsOrigin(root, (origin) => findAndInsertFetchedComments(origin, comments, parent_id)); } + +/** + * Nest a string in itself repeatly until `level` has been reached. + * + * Example: + * nest(` + * a + * ...nest + * b + * `, 2) + * + * Output: + * ` + * a + * a + * b + * b + * ` + */ +export function nest(document, level) { + let result = ''; + for (let x = 0; x < level; x++) { + if (x === 0) { + result += document; + continue; + } + result = result.replace('...nest', document); + } + return result.replace('...nest', ''); +} diff --git a/client/coral-embed-stream/src/index.js b/client/coral-embed-stream/src/index.js index 16c510c29..9020a8457 100644 --- a/client/coral-embed-stream/src/index.js +++ b/client/coral-embed-stream/src/index.js @@ -7,8 +7,8 @@ import './graphql'; import {addExternalConfig} from 'coral-embed-stream/src/actions/config'; import {getStore, injectReducers} from 'coral-framework/services/store'; import {getClient} from 'coral-framework/services/client'; +import pym from 'coral-framework/services/pym'; import AppRouter from './AppRouter'; -import {pym} from 'coral-framework'; import {loadPluginsTranslations, injectPluginsReducers} from 'coral-framework/helpers/plugins'; import reducers from './reducers'; @@ -48,5 +48,5 @@ render( - , document.querySelector('#coralStream') + , document.querySelector('#talk-embed-stream-container') ); diff --git a/client/coral-embed-stream/src/reducers/stream.js b/client/coral-embed-stream/src/reducers/stream.js index 2d6e3e8b2..4f4fcf47f 100644 --- a/client/coral-embed-stream/src/reducers/stream.js +++ b/client/coral-embed-stream/src/reducers/stream.js @@ -20,11 +20,19 @@ const initialState = { assetId: getQueryVariable('asset_id'), assetUrl: getQueryVariable('asset_url'), commentId: getQueryVariable('comment_id'), - commentClassNames: [] + commentClassNames: [], + activeTab: 'all', + previousTab: '', }; export default function stream(state = initialState, action) { switch (action.type) { + case actions.SET_ACTIVE_TAB: + return { + ...state, + activeTab: action.tab, + previousTab: state.activeTab, + }; case authActions.LOGOUT: return { ...state, diff --git a/client/coral-embed-stream/style/default.css b/client/coral-embed-stream/style/default.css index bd56cb748..887b780ce 100644 --- a/client/coral-embed-stream/style/default.css +++ b/client/coral-embed-stream/style/default.css @@ -17,32 +17,44 @@ body { height: auto !important; } -#coralStream { +#talk-embed-stream-container { padding: 4px; } +.talk-stream-tab-container { + padding-bottom: 50px; + min-height: 600px; +} + +.talk-stream-tab-container .material-icons { + vertical-align: middle; + width: 1em; + font-size: 1em; + overflow: hidden; +} + .expandForSignin { min-height: 600px; } -button { - margin: 5px 0px 5px 0px; +.coralButton { + margin: 5px 10px 5px 0px; background: none; padding: 0px; border: none; font-size: inherit; } -button:hover { +.coralButton:hover { border-radius: 2px; color: #767676; } -button i { +.coralButton i { margin-right: 3px; } -hr { +.coralHr { border: 0; height: 0; border-top: 1px solid rgba(0, 0, 0, 0.1); @@ -112,13 +124,6 @@ hr { color: #2a2a2a; } -.commentStream .material-icons { - vertical-align: middle; - width: 1em; - font-size: 1em; - overflow: hidden; -} - /* Question Box Styles */ .coral-plugin-questionbox-info { top: 0; @@ -181,21 +186,10 @@ hr { display: none; } -.talk-stream-wrapper-box { - padding: 10px 0; -} - .talk-stream-comments-container { position: relative; } -.commentStream { - /* prevent absolutely positioned final permalink popover from being clipped */ - padding-bottom: 50px; - min-height: 600px; - position: relative; -} - /* Comment Box Styles */ .talk-plugin-commentbox-container { display: flex; @@ -426,9 +420,8 @@ button.comment__action-button[disabled], .talk-new-comments { width: 100%; - display: flex; - justify-content: center; - cursor: pointer; + text-align: center; + margin: 4px 0; } .talk-load-more-replies { @@ -437,12 +430,6 @@ button.comment__action-button[disabled], box-sizing: border-box; } -.talk-new-comments { - position: relative; - top: 1.8em; - z-index: 100; -} - .talk-load-more-replies .talk-load-more-button { background-color: transparent; color: #979797; @@ -455,18 +442,4 @@ button.comment__action-button[disabled], color: white; } -.talk-new-comments button.talk-load-more{ - width: initial; -} - -@media (min-device-width : 300px) and (max-device-width : 420px) { - .commentActionsLeft.comment__action-container .coral-plugin-replies-reply-button { - visibility: collapse; - margin-left: -30px; - } - - .commentActionsLeft.comment__action-container .coral-plugin-replies-reply-button .coral-plugin-replies-icon { - visibility: visible; - } -} diff --git a/client/coral-framework/actions/auth.js b/client/coral-framework/actions/auth.js index 53d0f1212..732a11ebe 100644 --- a/client/coral-framework/actions/auth.js +++ b/client/coral-framework/actions/auth.js @@ -3,7 +3,7 @@ import bowser from 'bowser'; import * as actions from '../constants/auth'; import * as Storage from '../helpers/storage'; import coralApi, {base} from '../helpers/request'; -import pym from '../services/PymConnection'; +import pym from '../services/pym'; import {resetWebsocket} from 'coral-framework/services/client'; import t from 'coral-framework/services/i18n'; diff --git a/client/coral-framework/actions/index.js b/client/coral-framework/actions/index.js deleted file mode 100644 index 65f7b87d3..000000000 --- a/client/coral-framework/actions/index.js +++ /dev/null @@ -1,9 +0,0 @@ -import * as authActions from './auth'; -import * as assetActions from './asset'; -import * as notificationActions from './notification'; - -export default { - authActions, - assetActions, - notificationActions, -}; diff --git a/client/coral-framework/actions/notification.js b/client/coral-framework/actions/notification.js index d77a3a16c..57a7950f5 100644 --- a/client/coral-framework/actions/notification.js +++ b/client/coral-framework/actions/notification.js @@ -1,4 +1,4 @@ -import pym from '../services/PymConnection'; +import pym from '../services/pym'; import * as actions from '../constants/notification'; export const addNotification = (notifType, text) => { diff --git a/client/coral-framework/components/ClickOutside.js b/client/coral-framework/components/ClickOutside.js index e13ef4472..5348a9af7 100644 --- a/client/coral-framework/components/ClickOutside.js +++ b/client/coral-framework/components/ClickOutside.js @@ -1,7 +1,7 @@ import {Component, cloneElement, Children} from 'react'; import PropTypes from 'prop-types'; import {findDOMNode} from 'react-dom'; -import {pym} from 'coral-framework'; +import pym from 'coral-framework/services/pym'; export default class ClickOutside extends Component { static propTypes = { diff --git a/client/coral-framework/helpers/plugins.js b/client/coral-framework/helpers/plugins.js index ab5e3080e..d93bb30f9 100644 --- a/client/coral-framework/helpers/plugins.js +++ b/client/coral-framework/helpers/plugins.js @@ -10,17 +10,17 @@ import {loadTranslations} from 'coral-framework/services/i18n'; import {injectReducers, getStore} from 'coral-framework/services/store'; import camelize from './camelize'; -function getSlotComponents(slot) { +export function getSlotComponents(slot) { const pluginConfig = getStore().getState().config.plugin_config; - // Filter out components that have been disabled in `plugin_config` return flatten(plugins - // Filter out components that have been disabled in `plugin_config` - .filter((o) => !pluginConfig || !pluginConfig[o.plugin] || !pluginConfig[o.plugin].disable_components) + // Filter out components that have slots and have been disabled in `plugin_config` + .filter((o) => o.module.slots && (!pluginConfig || !pluginConfig[o.name] || !pluginConfig[o.name].disable_components)) .filter((o) => o.module.slots[slot]) - .map((o) => o.module.slots[slot])); + .map((o) => o.module.slots[slot]) + ); } export function isSlotEmpty(slot) { @@ -78,7 +78,7 @@ export function getSlotsFragments(slots) { } const components = uniq(flattenDeep(slots.map((slot) => { return plugins - .filter((o) => o.module.slots[slot]) + .filter((o) => o.module.slots ? o.module.slots[slot] : false) .map((o) => o.module.slots[slot]); }))); @@ -113,7 +113,22 @@ export function injectPluginsReducers() { const reducers = merge( ...plugins .filter((o) => o.module.reducer) - .map((o) => ({[camelize(o.plugin)] : o.module.reducer})) + .map((o) => ({[camelize(o.name)] : o.module.reducer})) ); injectReducers(reducers); } + +function addMetaDataToSlotComponents() { + + // Add talkPluginName to Slot Components. + plugins.forEach((plugin) => { + const slots = plugin.module.slots; + slots && Object.keys(slots).forEach((slot) => { + slots[slot].forEach((component) => { + component.talkPluginName = plugin.name; + }); + }); + }); +} + +addMetaDataToSlotComponents(); diff --git a/client/coral-framework/index.js b/client/coral-framework/index.js deleted file mode 100644 index 5cd9d6174..000000000 --- a/client/coral-framework/index.js +++ /dev/null @@ -1,10 +0,0 @@ -import pym from './services/PymConnection'; -import actions from './actions'; - -// TODO (bc): Deprecate old actions. Spreading actions is now needed. - -export default { - pym, - actions, - ...actions -}; diff --git a/client/coral-framework/loaders/plugins-loader.js b/client/coral-framework/loaders/plugins-loader.js index 4feae42ca..a9fed95ab 100644 --- a/client/coral-framework/loaders/plugins-loader.js +++ b/client/coral-framework/loaders/plugins-loader.js @@ -22,7 +22,7 @@ module.exports = function(source) { const config = this.exec(source, this.resourcePath); const plugins = getPluginList(config).map((plugin) => `{ module: require('${plugin}/client'), - plugin: '${plugin}' + name: '${plugin}' }`); return stripIndent` diff --git a/client/coral-framework/reducers/auth.js b/client/coral-framework/reducers/auth.js index 7ab12cf53..92d676abe 100644 --- a/client/coral-framework/reducers/auth.js +++ b/client/coral-framework/reducers/auth.js @@ -1,6 +1,6 @@ import {Map, fromJS} from 'immutable'; import * as actions from '../constants/auth'; -import {pym} from 'coral-framework'; +import pym from 'coral-framework/services/pym'; const initialState = Map({ isLoading: false, diff --git a/client/coral-framework/services/PymConnection.js b/client/coral-framework/services/pym.js similarity index 77% rename from client/coral-framework/services/PymConnection.js rename to client/coral-framework/services/pym.js index 6c492f2b8..bb5d90a0e 100644 --- a/client/coral-framework/services/PymConnection.js +++ b/client/coral-framework/services/pym.js @@ -1,4 +1,4 @@ -import Pym from '../../../node_modules/pym.js'; +import Pym from 'pym.js'; const pym = new Pym.Child({polling: 100}); export default pym; diff --git a/client/coral-framework/styles/reset.css b/client/coral-framework/styles/reset.css new file mode 100644 index 000000000..14ae4e723 --- /dev/null +++ b/client/coral-framework/styles/reset.css @@ -0,0 +1,29 @@ +.buttonReset { + + /* reset button */ + user-select: none; + outline: invert none medium; + border: none; + touch-action: manipulation; + padding: 0; + + position: relative; + overflow: hidden; + + /* Unify anchor and button. */ + cursor: pointer; + display: inline-block; + box-sizing: border-box; + text-align: center; + text-decoration: none; + align-items: flex-start; + vertical-align: middle; + whiteSpace: nowrap; + background: transparent; + font-size: inherit; + + -webkit-tap-highlight-color: rgba(0, 0, 0, 0) !important; + &::-moz-focus-inner: { + border: 0; + } +} diff --git a/client/coral-plugin-best/BestButton.css b/client/coral-plugin-best/BestButton.css new file mode 100644 index 000000000..b1f169ff6 --- /dev/null +++ b/client/coral-plugin-best/BestButton.css @@ -0,0 +1,4 @@ +.button { + composes: buttonReset from "coral-framework/styles/reset.css"; + margin: 5px 10px 5px 0px; +} diff --git a/client/coral-plugin-comment-count/CommentCount.js b/client/coral-plugin-comment-count/CommentCount.js deleted file mode 100644 index cca99e729..000000000 --- a/client/coral-plugin-comment-count/CommentCount.js +++ /dev/null @@ -1,17 +0,0 @@ -import React, {PropTypes} from 'react'; - -import t from 'coral-framework/services/i18n'; - -const name = 'coral-plugin-comment-count'; - -const CommentCount = ({count}) => { - return
- {`${count} ${count === 1 ? t('comment_singular') : t('comment_plural')}`} -
; -}; - -CommentCount.propTypes = { - count: PropTypes.number.isRequired -}; - -export default CommentCount; diff --git a/client/coral-plugin-flags/components/FlagButton.js b/client/coral-plugin-flags/components/FlagButton.js index 25fcf21a9..33499e83d 100644 --- a/client/coral-plugin-flags/components/FlagButton.js +++ b/client/coral-plugin-flags/components/FlagButton.js @@ -148,7 +148,7 @@ export default class FlagButton extends Component { diff --git a/client/coral-settings/containers/ProfileContainer.js b/client/coral-settings/containers/ProfileContainer.js index 5e343e72f..a4cb9459f 100644 --- a/client/coral-settings/containers/ProfileContainer.js +++ b/client/coral-settings/containers/ProfileContainer.js @@ -5,7 +5,7 @@ import {bindActionCreators} from 'redux'; import {withStopIgnoringUser} from 'coral-framework/graphql/mutations'; -import {link} from 'coral-framework/services/PymConnection'; +import {link} from 'coral-framework/services/pym'; import NotLoggedIn from '../components/NotLoggedIn'; import IgnoredUsers from '../components/IgnoredUsers'; import {Spinner} from 'coral-ui'; diff --git a/client/coral-ui/components/Tab.css b/client/coral-ui/components/Tab.css index 1d353c8fe..b3c026b3d 100644 --- a/client/coral-ui/components/Tab.css +++ b/client/coral-ui/components/Tab.css @@ -1,9 +1,70 @@ -li.base--active { - background: white; - font-weight: bold; +.root { + display: inline-block; + margin-right: -1px; + margin-bottom: -1px; } -li.material--active { - font-weight: bold; - border-bottom: solid 2px black; +.rootActive { + } + +.rootSub { + display: inline-block; + margin-bottom: -2px; +} + +.rootSubActive { + +} + +.button { + composes: buttonReset from "coral-framework/styles/reset.css"; + padding: 8px 10px; + color: #4E5259; + border: solid 1px #D8D8D8; + background: #F0F0F0; + border-top-left-radius: 5px; + border-top-right-radius: 5px; + font-size: 13px; + outline: none; +} + +.button:hover, .button:focus { + background: #d5d5d5; + border-bottom: 1px solid #d5d5d5; +} + +.buttonActive, .buttonActive:hover, .buttonActive:focus { + background: white; + font-weight: bold; + border-bottom: 1px solid white; +} + +.buttonSub { + composes: buttonReset from "coral-framework/styles/reset.css"; + color: black; + border: none; + background: transparent; + padding: 6px 12px; + margin-bottom: 3px; + outline: none; +} + +.buttonSub:hover, .buttonSub:focus { + background: transparent; + border-bottom: solid 3px #d5d5d5; + margin-bottom: 0px; +} + +.buttonSubActive, .buttonSubActive:hover, .buttonSubActive:focus { + font-weight: bold; + border-bottom: solid 3px #10589b; + margin-bottom: 0px; + padding: 6px 10px; +} + +.root:only-child .button, .rootSub:only-child .buttonSub { + cursor: default; + pointer-events: none; +} + diff --git a/client/coral-ui/components/Tab.js b/client/coral-ui/components/Tab.js index 492c35cbe..4fd85b303 100644 --- a/client/coral-ui/components/Tab.js +++ b/client/coral-ui/components/Tab.js @@ -1,12 +1,109 @@ import React from 'react'; import styles from './Tab.css'; +import cn from 'classnames'; +import PropTypes from 'prop-types'; -export default ({children, tabId, active, onTabClick, cStyle = 'base', ...props}) => ( -
  • onTabClick(tabId)} - > - {children} -
  • -); +/** + * The `Tab` component is used inside the `TabBar` Component, to + * render tabs. + */ +class Tab extends React.Component { + handleTabClick = () => { + if (this.props.onTabClick) { + this.props.onTabClick(this.props.tabId); + } + } + + getRootClassName({active, className, sub, classNames = {}} = this.props) { + return cn( + 'talk-tab', + className, + { + [classNames.root || styles.root]: !sub, + [classNames.rootSub || styles.rootSub]: sub, + [classNames.rootActive || styles.rootActive]: active && !sub, + [classNames.rootSubActive || styles.rootSubActive]: active && sub, + 'talk-tab-active': active, + } + ); + } + + getButtonClassName({sub, active, classNames = {}} = this.props) { + return cn( + 'talk-tab-button', + { + [classNames.button || styles.button]: !sub, + [classNames.buttonSub || styles.buttonSub]: sub, + [classNames.buttonActive || styles.buttonActive]: active && !sub, + [classNames.buttonSubActive || styles.buttonSubActive]: active && sub, + } + ); + } + + render() { + const { + children, + classNames: _a, + active, + onTabClick: _c, + tabId: _d, + sub: _e, + 'aria-controls': ariaControls, + ...rest, + } = this.props; + + return ( +
  • + +
  • + ); + } +} + +Tab.propTypes = { + + // className to be added to the root element. + className: PropTypes.string, + + // classNames allows full design customization of the component. + classNames: PropTypes.shape({ + root: PropTypes.string, + rootActive: PropTypes.string, + rootSub: PropTypes.string, + rootSubActive: PropTypes.string, + button: PropTypes.string, + buttonActive: PropTypes.string, + buttonSub: PropTypes.string, + buttonSubActive: PropTypes.string, + }), + + // active indicates that this tab is currently active. + // This is injected by the `TabBar` component. + active: PropTypes.bool, + + // onTabClick is fired whenever the tab was clicked. The tabId is passed as + // the first argument. + onTabClick: PropTypes.func, + + // Sub indicates that this is a tab of a sub-tab-panel. + // This is injected by the `TabBar` component. + sub: PropTypes.bool, + + // `aria-controls` should be set to the `id` of the `TabContent` for accessibility. + // This is injected by the `TabBar` component. + 'aria-controls': PropTypes.string, +}; + +export default Tab; diff --git a/client/coral-ui/components/TabBar.css b/client/coral-ui/components/TabBar.css index b5d44d570..181fa1ddc 100644 --- a/client/coral-ui/components/TabBar.css +++ b/client/coral-ui/components/TabBar.css @@ -1,44 +1,14 @@ -.base { +.root { list-style: none; border-bottom: solid 1px #D8D8D8; padding: 0; + margin: 0; } -.base li { - color: #4E5259; - border: solid 1px #D8D8D8; - background: #F0F0F0; - border-top-left-radius: 5px; - border-top-right-radius: 5px; - display: inline-block; - border-bottom: none; - padding: 8px 10px; - margin-right: -1px; - user-select: none; - font-size: 13px; -} - -.base li:hover { - background: #d5d5d5; - cursor: pointer; -} - -.material { +.rootSub { list-style: none; border: none; padding: 0; -} - -.material li { - color: black; - border: none; - border-bottom: solid 2px white; - background: white; - padding: 8px 0; - margin-right: 40px; -} - -.material li:hover { - background: white; - border-bottom: solid 2px grey; + margin: 0; + border-bottom: solid 2px #eee; } diff --git a/client/coral-ui/components/TabBar.js b/client/coral-ui/components/TabBar.js index 08f466ffc..1a51c60f2 100644 --- a/client/coral-ui/components/TabBar.js +++ b/client/coral-ui/components/TabBar.js @@ -1,37 +1,86 @@ import React from 'react'; import styles from './TabBar.css'; +import cn from 'classnames'; +import Tab from './Tab'; +import PropTypes from 'prop-types'; +/** + * The `TabBar` component accepts `Tab` components to create + * a tab bar. + */ class TabBar extends React.Component { - constructor(props) { - super(props); - this.handleClickTab = this.handleClickTab.bind(this); - } - handleClickTab(tabId) { - if (this.props.onChange) { - this.props.onChange(tabId); - } + getRootClassName({className, classNames = {}, sub} = this.props) { + return cn( + 'talk-tab-bar', + className, + { + [classNames.root || styles.root]: !sub, + [classNames.rootSub || styles.rootSub]: sub, + } + ); } render() { - const {children, activeTab, cStyle = 'base'} = this.props; + const { + children, + activeTab, + tabClassNames, + classNames: _a, + onTabClick: _b, + 'aria-controls': ariaControls, + sub, + ...rest, + } = this.props; + return ( -
    -
      - {React.Children.toArray(children) - .filter((child) => !child.props.restricted) - .map((child, tabId) => - React.cloneElement(child, { - tabId, - active: child.props.id === activeTab, - onTabClick: this.handleClickTab, - cStyle - }) - )} -
    -
    +
      + {React.Children.toArray(children) + .map((child, i) => + React.cloneElement(child, { + tabId: (child.props.tabId !== undefined) ? child.props.tabId : i, + active: child.props.tabId === activeTab, + onTabClick: this.props.onTabClick, + classNames: tabClassNames, + 'aria-controls': ariaControls, + sub, + }) + )} +
    ); } } +TabBar.propTypes = { + + // className to be added to the root element. + className: PropTypes.string, + + // classNames allows full design customization of the component. + classNames: PropTypes.shape({ + root: PropTypes.string, + rootSub: PropTypes.string, + }), + + // classNames to be passed to the children. + tabClassNames: Tab.propTypes.classNames, + + // activeTab should be set to the currently active tabId. + activeTab: PropTypes.string, + + // onTabClick is fired whenever the tab was clicked. The tabId is passed as + // the first argument. + onTabClick: PropTypes.func, + + // Sub indicates that this is a sub-tab-panel. + sub: PropTypes.bool, + + // `aria-controls` should be set to the `id` of the `TabContent` for accessibility. + 'aria-controls': PropTypes.string, +}; + export default TabBar; diff --git a/client/coral-ui/components/TabContent.css b/client/coral-ui/components/TabContent.css new file mode 100644 index 000000000..debafa97c --- /dev/null +++ b/client/coral-ui/components/TabContent.css @@ -0,0 +1,3 @@ +.root { + padding-top: 10px; +} diff --git a/client/coral-ui/components/TabContent.js b/client/coral-ui/components/TabContent.js index 737c9a5e6..99676f7cc 100644 --- a/client/coral-ui/components/TabContent.js +++ b/client/coral-ui/components/TabContent.js @@ -1,6 +1,43 @@ import React from 'react'; +import cn from 'classnames'; +import PropTypes from 'prop-types'; +import styles from './TabContent.css'; -export default ({children, show = true}) => ( - show ?
    {children}
    : null +function getRootClassName(className) { + return cn('talk-tab-content', className, styles.root); +} + +/** + * The `TabContent` component accepts `TabPane` components to render + * the content of a `Tab`. + */ +const TabContent = ({children, className, activeTab, sub, ...rest}) => ( +
    + { + React.Children.toArray(children) + .filter((child) => child.props.tabId === activeTab) + .map((child, i) => + React.cloneElement(child, { + tabId: (child.props.tabId !== undefined) ? child.props.tabId : i, + sub, + })) + } +
    ); +TabContent.propTypes = { + + // className to be added to the root element. + className: PropTypes.string, + + // activeTab should be set to the currently active tabId. + activeTab: PropTypes.string, + + // Sub indicates that this component belongs to a sub-tab-panel. + sub: PropTypes.bool, +}; + +export default TabContent; diff --git a/client/coral-ui/components/TabCount.css b/client/coral-ui/components/TabCount.css new file mode 100644 index 000000000..f95792a0b --- /dev/null +++ b/client/coral-ui/components/TabCount.css @@ -0,0 +1,18 @@ + +.root, .rootSub { + display: inline-block; + position: relative; + top: -2px; + background: #616161; + color: white; + font-weight: normal; + font-size: 10px; + padding: 2px; + margin-left: 2px; + margin-top: -2px; + min-width: 20px; +} + +.rootSubActive { + background: #10589b; +} diff --git a/client/coral-ui/components/TabCount.js b/client/coral-ui/components/TabCount.js new file mode 100644 index 000000000..01a3840a9 --- /dev/null +++ b/client/coral-ui/components/TabCount.js @@ -0,0 +1,47 @@ +import React from 'react'; +import cn from 'classnames'; +import styles from './TabCount.css'; +import PropTypes from 'prop-types'; + +function getNumber(no) { + let result = Number.parseInt(no); + if (no >= 1000) { + result = `${Math.round(result / 100) / 10}k`; + } + return result; +} + +function getRootClassName({className, active, sub}) { + return cn( + 'talk-tab-count', + className, + { + [styles.root]: !sub, + [styles.rootSub]: sub, + [styles.rootActive]: active && !sub, + [styles.rootSubActive]: active && sub, + 'talk-tab-active': active, + } + ); +} + +/** + * The `TabCount` renders a count number next to a tab name. + */ +const TabCount = ({children, active, sub, className}) => ( + {getNumber(children)} +); + +TabCount.propTypes = { + + // className to be added to the root element. + className: PropTypes.string, + + // active indicates that the related tab is currently active. + active: PropTypes.bool, + + // Sub indicates that this component belongs to a sub-tab-panel. + sub: PropTypes.bool, +}; + +export default TabCount; diff --git a/client/coral-ui/components/TabPane.js b/client/coral-ui/components/TabPane.js new file mode 100644 index 000000000..16750b335 --- /dev/null +++ b/client/coral-ui/components/TabPane.js @@ -0,0 +1,34 @@ +import React from 'react'; +import cn from 'classnames'; +import PropTypes from 'prop-types'; + +function getRootClassName(className) { + return cn('talk-pane', className); +} + +/** + * The `TabPane` component is used inside the `TabContent` component to render + * the content of a `Tab`. + */ +const TabPane = ({children, className, tabId: _a, sub: _b, ...rest}) => ( +
    + {children} +
    +); + +TabPane.propTypes = { + + // className to be added to the root element. + className: PropTypes.string, + + tabId: PropTypes.string, + + // Sub indicates that this component belongs to a sub-tab-panel. + // This is injected by the `TabContent` component. + sub: PropTypes.bool, +}; + +export default TabPane; diff --git a/client/coral-ui/index.js b/client/coral-ui/index.js index 3e9456727..8a538d120 100644 --- a/client/coral-ui/index.js +++ b/client/coral-ui/index.js @@ -4,7 +4,9 @@ export {default as CoralLogo} from './components/CoralLogo'; export {default as FabButton} from './components/FabButton'; export {default as TabBar} from './components/TabBar'; export {default as Tab} from './components/Tab'; +export {default as TabCount} from './components/TabCount'; export {default as TabContent} from './components/TabContent'; +export {default as TabPane} from './components/TabPane'; export {default as Button} from './components/Button'; export {default as Spinner} from './components/Spinner'; export {default as Tooltip} from './components/Tooltip'; diff --git a/config.js b/config.js index 02f95d070..ce8931235 100644 --- a/config.js +++ b/config.js @@ -29,7 +29,7 @@ const CONFIG = { // JWT_ISSUER is the value for the issuer for the tokens that will be verified // when decoding. If `JWT_ISSUER` is not in the environment, then it will try // `TALK_ROOT_URL`, otherwise, it will be undefined. - JWT_ISSUER: process.env.TALK_JWT_ISSUER || process.env.TALK_ROOT_URL || undefined, + JWT_ISSUER: process.env.TALK_JWT_ISSUER || process.env.TALK_ROOT_URL, // JWT_EXPIRY is the time for which a given token is valid for. JWT_EXPIRY: process.env.TALK_JWT_EXPIRY || '1 day', @@ -91,7 +91,9 @@ const CONFIG = { if (process.env.NODE_ENV === 'test' && !CONFIG.JWT_SECRET) { CONFIG.JWT_SECRET = 'keyboard cat'; } else if (!CONFIG.JWT_SECRET) { - throw new Error('TALK_JWT_SECRET must be provided in the environment to sign/verify tokens'); + throw new Error( + 'TALK_JWT_SECRET must be provided in the environment to sign/verify tokens' + ); } //------------------------------------------------------------------------------ @@ -108,7 +110,7 @@ if (process.env.NODE_ENV === 'test' && !CONFIG.MONGO_URL) { // Reset the redis url in the event it hasn't been overrided and we are in a // testing environment. if (process.env.NODE_ENV === 'test' && !CONFIG.REDIS_URL) { - CONFIG.REDIS_URL = 'redis://localhost'; + CONFIG.REDIS_URL = 'redis://localhost/1'; } //------------------------------------------------------------------------------ @@ -119,10 +121,15 @@ if (process.env.NODE_ENV === 'test' && !CONFIG.REDIS_URL) { * This is true when the recaptcha secret is provided and the Recaptcha feature * is to be enabled. */ -CONFIG.RECAPTCHA_ENABLED = CONFIG.RECAPTCHA_SECRET && CONFIG.RECAPTCHA_SECRET.length > 0 && - CONFIG.RECAPTCHA_PUBLIC && CONFIG.RECAPTCHA_PUBLIC.length > 0; +CONFIG.RECAPTCHA_ENABLED = + CONFIG.RECAPTCHA_SECRET && + CONFIG.RECAPTCHA_SECRET.length > 0 && + CONFIG.RECAPTCHA_PUBLIC && + CONFIG.RECAPTCHA_PUBLIC.length > 0; if (!CONFIG.RECAPTCHA_ENABLED) { - console.warn('Recaptcha is not enabled for login/signup abuse prevention, set TALK_RECAPTCHA_SECRET and TALK_RECAPTCHA_PUBLIC to enable Recaptcha.'); + console.warn( + 'Recaptcha is not enabled for login/signup abuse prevention, set TALK_RECAPTCHA_SECRET and TALK_RECAPTCHA_PUBLIC to enable Recaptcha.' + ); } //------------------------------------------------------------------------------ @@ -138,7 +145,13 @@ if (!CONFIG.RECAPTCHA_ENABLED) { ]; if (requiredProps.some((prop) => !CONFIG[prop])) { - console.warn(`${requiredProps.map((v) => `TALK_${v}`).join(', ')} should be defined in the environment if you would like to send password reset emails from Talk`); + console.warn( + `${requiredProps + .map((v) => `TALK_${v}`) + .join( + ', ' + )} should be defined in the environment if you would like to send password reset emails from Talk` + ); } } diff --git a/docs/_data/sidebars/talk_sidebar.yml b/docs/_data/sidebars/talk_sidebar.yml index b44c9648f..62660783c 100644 --- a/docs/_data/sidebars/talk_sidebar.yml +++ b/docs/_data/sidebars/talk_sidebar.yml @@ -37,6 +37,20 @@ entries: url: /install-setup.html output: web + - title: Architecture + output: web + folderitems: + - title: Overview + url: /architecture.html + output: web + - title: Tags + url: /architecture-tags.html + output: web + - title: cli + url: /architecture-cli.html + output: web + + - title: Plugins output: web folderitems: diff --git a/docs/architecture-cli.md b/docs/architecture-cli.md new file mode 100644 index 000000000..3079890d4 --- /dev/null +++ b/docs/architecture-cli.md @@ -0,0 +1,139 @@ +--- +title: The Talk cli +keywords: architecture +sidebar: talk_sidebar +permalink: architecture-cli.html +summary: +--- + +Talk ships with a cli tool that allows access to a wide variety of functionality. + +It is designed to provide a convenient way for engineers to perform key tasks without the need to muck about in the UI. It also opens the door for scripts to perform operations programmatically. + +Note: the cli tool requires [the Talk environment to be configured](configuration.html) either via env vars or by using a `.cli` file via `bin/cli -c .env [command] ....` + +## Using the cli + +In a terminal, try: + +``` +/path/to/talk/bin cli [options] [commands] [arguments] +``` + +Commonly, you'll be in the `talk/` folder, in which case you can: + +``` +bin/cli [options] [commands] [arguments] +``` + +If you're a heavy cli user, consider adding the `bin` folder to your PATH so you can run `cli` from anywhere! + +If you are using [our Docker environment](install-docker.html), the bin folder will already be in the PATH. + +## What can I do with the cli? + +The Talk cli ships with 'unix style' help. To access the docs, simply run the cli with insufficient arguments. + +Let's say I wanted to figure out how to change a user's password. I'd start be seeing what the cli has for me. + +(Note: the following output may change, please reference at the `--help` output for your version as you use the cli.) + +``` +talk :) ]$ bin/cli --help + + Usage: cli [options] [command] + + + Commands: + + serve serve the application + settings interact with the application settings + assets interact with assets + setup setup the application + jobs work with the job queues + token work with the access tokens + users work with the application auth + migration provides utilities for migrating the database + plugins provides utilities for interacting with the plugin system + help [cmd] display help for [cmd] + + Options: + + -h, --help output usage information + -V, --version output the version number + -c, --config [path] Specify the configuration file to load + --pid [path] Specify a path to output the current PID to +``` + +Most commands contain sub-commands. Running with cli with such a command generates the docs for the sub-commands and options therein. + +Change user password is likely to be in the `users` command group. + +``` +talk :) ]$ bin/cli users + + Usage: cli-users [options] [command] + + + Commands: + + create [options] create a new user + delete delete a user + passwd change a password for a user + update [options] update a user + list list all the users in the database + merge merge srcUser into the dstUser + addrole adds a role to a given user + removerole removes a role from a given user + ban ban a given user + uban unban a given user + disable disable a given user from logging in + enable enable a given user from logging in + + Options: + + -h, --help output usage information + -V, --version output the version number + -c, --config [path] Specify the configuration file to load + --pid [path] Specify a path to output the current PID to +``` + +I now see that I can change a password like so: + +``` +bin/cli users passwd [userID] +``` + +You can also read these help prompts by [exploring the source code](https://github.com/coralproject/talk/blob/master/bin/cli). + +### Usage Notes + +If you haven't used a cli enabled system before, think of it like this: generally, you'd make a rest call, rpc, etc... to perform an action. The cli's api is designed in the same way, just for the audience of command line wielding engineers and scripts. + +The best way to understand what the cli does is to explore the help commands. Uses of cli are also scattered through this documentation in context of their topics. + +For some real world uses of the cli, see the scripts in the [package.json file](https://github.com/coralproject/talk/blob/d688f70c19d8dee48371784009fd07322dae4eb5/package.json#L8). + +## What's really going on when I run the cli? + +The cli tool is a standalone application. Running it starts up the internals of a talk process, executes the given command, then shuts it down. No server functionality is enabled by cli commands unless specifically noted. + +The cli tool _does not require a talk server to be running._ This means that you can execute commands, for example, during and installation process before starting the server. The also means that you can execute commands using varying configurations (via the `-c [.env file]` flag). + +### Accessing existing Talk installs with the cli + +You may use the cli tool to 'remotely' control existing talk installs. + +This is accomplished by running the cli tool on any box using the mongo/redis/etc... credentials for the environment that you would like to act on. For example, if you want something to happen periodically on your production Talk install, you could set up a utility box with a cron job that triggers the cli with the same db/cache credentials. If you want to do something quick on a staging server, you could run the cli locally with staging credentials. + +The cli tool will connect directly with the install's db and redis instance(s) so ensure that your box can reach those servers on the appropriate ports. + +Also, _please ensure your cli and the server(s) in an environment are using the same version of Talk._ + +Please secure your environments and credentials or the cli tool becomes a convenient way for someone to own your system. + +## Extending the cli + +The Talk cli is based on the excellent [commander](https://github.com/tj/commander.js/) library. + +At the time of writing, there are no plugin hooks for the cli tool. If you would like to change this, whether by writing code yourself or recommending a need, please [write and issue](https://github.com/coralproject/talk/blob/053b687959d45bcd682a1a2a4b604ebfab7441bb/CONTRIBUTING.md#writing-code). diff --git a/docs/architecture-tags.md b/docs/architecture-tags.md new file mode 100644 index 000000000..06c5d3498 --- /dev/null +++ b/docs/architecture-tags.md @@ -0,0 +1,71 @@ +--- +title: Tags +keywords: architecture +sidebar: talk_sidebar +permalink: architecture-tags.html +summary: +--- + +Tags are essentially strings that can be added to models. Currently, tags can be added to [Users, Comments and Assets](https://github.com/coralproject/talk/blob/ced449a1489d47c25d604020fa2e0b3b7a741353/graph/typeDefs.graphql#L144). If you would like to add tags to other models, you can extend this schema using [GraphQL hooks](plugins-server.html#graphql-hooks). + +## Tag Definitions + +When handling tags, the Talk Server references a set of definitions that describe how tags are handled. These definitions are keyed off the tag `name`, the simple string that is stored on items. + +The schema for Tag definitions [can be found here](https://github.com/coralproject/talk/blob/3545bf01cd91044fdb738d337a0ac94d9f71fbc3/models/schema/tag.js). + +Note that along with the `name`, tag definitions contains: + +* `permissions` information about who can see and set the tag, +* `models` which `ITEM_TYPES` this tag can be applied to, and + +Whenever a tag is 'handled' by the server, it references this definition to determine that tag's behavior. + +See [Plugin API Documentation](plugins-server.html#field-tags) for more information. + +### Creating a Tag Definition + +Tag Definitions must be created in order for the system to determine what tags are permitted on the server side. + +Tag Definitions do not contain any logic themselves but provide information that other parts of the system can use to specify which models a tag can be applied to (models) and perform authorization logic (permissions). + +Take the tag created by `coral-plugin-offtopic` as an example. + +``` +// coral-plugin-offtopic/index.js +module.exports = { + tags: [ + { + name: 'OFF_TOPIC', + permissions: { + public: true, + self: true, + roles: [] + }, + models: ['COMMENTS'], + created_at: new Date() + } + ] +}; +``` + +This plugin allows users to self-report that their comment is "off topic" at the time of creation, then display a badge on those comments. + +To accomplish this, the plugin creates the tag `OFF_TOPIC` with: + +* `permissions.public: true` - will be sent over the wire to the client side +* `permissions.self: true` - can be added by the active user to themselves or assets they own +* `permissions.roles: []` - cannot be added by anyone based on their roles +* `models: ['COMMENTS']` - can only be added to COMMENTS (not to users/assets/etc...) + +And [viola](https://youtu.be/Q0O9gFf-tiI?t=23s)! This tag is something that can only be created by the logged in user on their own comments and is sent over the wire to the client so it can display the badge. + +## Tag Links + +When tags are stored on objects in the database, they are represented by [TagLinks](https://github.com/coralproject/talk/blob/master/models/schema/tag_link.js). + +A TagLinks says that `tag` was `assigned_by` a specific user at a specific time (`created_at`). + +Note that the `tag` field in the TagLinkSchema is the full TagSchema itself. This allows for another level of flexibility. Server code may generate Tags on the fly, complete with programmatically generated permissions and item behaviors. + +If a Tag definitions exists in the global/asset context then that definition will be used regardless of what is stored here. This allows high level controls on the behavior of tags, ensuring that plugins cannot produce unexpected definitions for already defined tags. diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 000000000..180e172ac --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,80 @@ +--- +title: Architecture Overview +keywords: architecture +sidebar: talk_sidebar +permalink: architecture.html +summary: +--- + +## Talk's Architecture + +Talk consists of four distinct layers of code: + +* Plugins +* Plugin API +* Core +* cli + +### Plugins + +Talk plugins deliver the features and functionality that can be changed or removed. Much of the default functionality is delivered by plugins allowing developers to change behavior along product lines that we've found to be important. + +### Plugin API + +Talk plugins interact exclusively with the Plugin API. Maintaining this layer of separation between plugins and core allows us to consciously design the api that we want it publish to plugin authors. We can then expose just the elements of core that make sense and maintain that contract as core changes. + +### Core + +Talk core consists of architecture and functionality that deliver stability, security, scalability, extendability, etc... In addition, the Core contains features and functionality that is essential to the operation of Talk as a product. + +Our goal is to continually extend our plugin infrastructure making the Core as pluggable as possible. Ultimately, a day may come where the Core of Talk is simply a framework for delivering a certain flavor of web applications. + +### cli + +Talk ships with [a cli tool](architecture-cli.html) that exposes functionality to the command line. We seek to provide cli functionality for all features that could need to be accomplished programmatically or prior to the server's startup. + +## Thinking about Plugins, the Plugin API and Core? + +The following is a template for a thought process that may help clarify your ideas against the backdrop of Talk's architecture. + +Think of a feature or capability. It could be something that's already in Talk or not. It could be something you want to build, or something you'd think would be a terrible idea. The important part here is to have something to interrogate. + +``` +wait(60000); +``` + +Now, ask these questions: + +### Is it a Plugin? + +Most work for Talk happens in the Plugin space. If the answers to any of these questions is Yes, then you're thinking of a Plugin. + +* Does Talk's existing Plugin APIs support the thing you want to build? +* Is this something that only some users will want/need? +* Is this something that we want devs to iterate on widely? + +You should [build it as a plugin](plugins-quickstart.html). Feel free to explore here on your own or reach out to us. We love to advise on plugins, so please feel free to [file an issue](https://github.com/coralproject/talk/blob/master/CONTRIBUTING.md) and we will start a conversation. We will help you conceptualize, architect and promote your plugin if it is in line with our values. + +### Does it need updates to the Plugin API? + +If you answered yes above: + +* Do I need to extend the Plugin API to support my plugin? + +Often times all the functionality a plugin needs is in the Core, but the Plugin API doesn't expose it. In these cases, we seek to iteratively extend the Talk Plugin API. All Plugin API contributions from the community must begin by [filing an Issue](https://github.com/coralproject/talk/blob/master/CONTRIBUTING.md). + +Note: we are stabilizing the process by which new Plugin API bindings are created, agreed upon and ultimately made part of our Plugins Contract. If you are interested in this process, please reach out to us. + +### Does it require updates to the Plugin API _and_ Core? + +What, if any, changes need to be made to Core so that the API can be extended? + +Quite often the only things missing from Core are things like _events_, _slots_, _CSS classes_, etc... Adding these is a great way to become a Core Contributor and break new ground as a Plugin Developer. + +We seek to keep Core as lean as possible. + +### Is my idea really just Core? + +Amazing! We are always looking to extend the capabilities of Talk. We look forward to discussing what you've got to bring! + +Please see our [contributing guide](](https://github.com/coralproject/talk/blob/master/CONTRIBUTING.md)) for more information. diff --git a/docs/plugins-quickstart.md b/docs/plugins-quickstart.md index 77de5e8a0..b69c14d72 100644 --- a/docs/plugins-quickstart.md +++ b/docs/plugins-quickstart.md @@ -215,7 +215,7 @@ It is important to realize that when you're writing a Talk plugin you are writin ### Publish to npm -In order to [register](http://localhost:4000/plugins.html#plugin-registration) your _published_ plugin, you will need to [publish it to npm](https://docs.npmjs.com/getting-started/publishing-npm-packages). +In order to [register](plugins.html#plugin-registration) your _published_ plugin, you will need to [publish it to npm](https://docs.npmjs.com/getting-started/publishing-npm-packages). Once the package is published, update `plugins.json` to use the published plugin: diff --git a/graph/loaders/comments.js b/graph/loaders/comments.js index 8360f88ce..b0475d3ef 100644 --- a/graph/loaders/comments.js +++ b/graph/loaders/comments.js @@ -50,13 +50,20 @@ const getCountsByAssetID = (context, asset_ids) => { * @param {Array} id The ID of the asset * @param {Array} excludeIgnored Exclude comments ignored by the requesting user */ -const getCountsByAssetIDPersonalized = async (context, {assetId, excludeIgnored}) => { +const getCountsByAssetIDPersonalized = async (context, {assetId, excludeIgnored, tags}) => { const query = { asset_id: assetId, status: { $in: ['NONE', 'ACCEPTED'], }, }; + + if (tags) { + query['tags.tag.name'] = { + $in: tags, + }; + } + const user = context.user; if (excludeIgnored && user) { @@ -108,7 +115,7 @@ const getParentCountsByAssetID = (context, asset_ids) => { * @param {Array} id The ID of the asset * @param {Array} excludeIgnored Exclude comments ignored by the requesting user */ -const getParentCountByAssetIDPersonalized = async (context, {assetId, excludeIgnored}) => { +const getParentCountByAssetIDPersonalized = async (context, {assetId, excludeIgnored, tags}) => { const query = { asset_id: assetId, parent_id: null, @@ -116,6 +123,13 @@ const getParentCountByAssetIDPersonalized = async (context, {assetId, excludeIgn $in: ['NONE', 'ACCEPTED'], }, }; + + if (tags) { + query['tags.tag.name'] = { + $in: tags, + }; + } + const user = context.user; if (excludeIgnored && user) { @@ -195,7 +209,7 @@ const getCountByParentIDPersonalized = async (context, {id, excludeIgnored}) => * @return {Promise} resolves to the counts of the comments from the * query */ -const getCommentCountByQuery = (context, {ids, statuses, asset_id, parent_id, author_id}) => { +const getCommentCountByQuery = (context, {ids, statuses, asset_id, parent_id, author_id, tags}) => { let query = CommentModel.find(); if (ids) { @@ -218,6 +232,14 @@ const getCommentCountByQuery = (context, {ids, statuses, asset_id, parent_id, au query = query.where({author_id}); } + if (tags) { + query = query.find({ + 'tags.tag.name': { + $in: tags, + }, + }); + } + return CommentModel .find(query) .count(); @@ -229,7 +251,7 @@ const getCommentCountByQuery = (context, {ids, statuses, asset_id, parent_id, au * @param {Object} context graph context * @param {Object} query query terms to apply to the comments query */ -const getCommentsByQuery = async ({user}, {ids, statuses, asset_id, parent_id, author_id, limit, cursor, sort, excludeIgnored}) => { +const getCommentsByQuery = async ({user}, {ids, statuses, asset_id, parent_id, author_id, limit, cursor, sort, excludeIgnored, tags}) => { let comments = CommentModel.find(); // Only administrators can search for comments with statuses that are not @@ -256,6 +278,14 @@ const getCommentsByQuery = async ({user}, {ids, statuses, asset_id, parent_id, a }); } + if (tags) { + comments = comments.find({ + 'tags.tag.name': { + $in: tags, + }, + }); + } + // Only let an admin request any user or the current user request themself. if (user && (user.can(SEARCH_OTHERS_COMMENTS) || user.id === author_id) && author_id != null) { comments = comments.where({author_id}); diff --git a/graph/resolvers/asset.js b/graph/resolvers/asset.js index f6793fbf1..86e102631 100644 --- a/graph/resolvers/asset.js +++ b/graph/resolvers/asset.js @@ -4,31 +4,32 @@ const Asset = { recentComments({id}, _, {loaders: {Comments}}) { return Comments.genRecentComments.load(id); }, - comments({id}, {sort, limit, excludeIgnored}, {loaders: {Comments}}) { + comments({id}, {sort, limit, deep, excludeIgnored, tags}, {loaders: {Comments}}) { return Comments.getByQuery({ asset_id: id, sort, limit, - parent_id: null, + parent_id: deep ? undefined : null, + tags, excludeIgnored, }); }, - commentCount({id, commentCount}, {excludeIgnored}, {user, loaders: {Comments}}) { - + commentCount({id, commentCount}, {excludeIgnored, tags}, {user, loaders: {Comments}}) { + // TODO: remove - if (user && excludeIgnored) { - return Comments.parentCountByAssetIDPersonalized({assetId: id, excludeIgnored}); + if ((user && excludeIgnored) || tags) { + return Comments.parentCountByAssetIDPersonalized({assetId: id, excludeIgnored, tags}); } if (commentCount != null) { return commentCount; } return Comments.parentCountByAssetID.load(id); }, - totalCommentCount({id, totalCommentCount}, {excludeIgnored}, {user, loaders: {Comments}}) { + totalCommentCount({id, totalCommentCount}, {excludeIgnored, tags}, {user, loaders: {Comments}}) { // TODO: remove - if (user && excludeIgnored) { - return Comments.countByAssetIDPersonalized({assetId: id, excludeIgnored}); + if ((user && excludeIgnored) || tags) { + return Comments.countByAssetIDPersonalized({assetId: id, excludeIgnored, tags}); } if (totalCommentCount != null) { return totalCommentCount; diff --git a/graph/typeDefs.graphql b/graph/typeDefs.graphql index 4a38ce266..a154f9f90 100644 --- a/graph/typeDefs.graphql +++ b/graph/typeDefs.graphql @@ -243,12 +243,12 @@ input CommentsQuery { # Skip results from the last created_at timestamp. cursor: Date - # Filter by a specific tag name. - tag: [String] - # Sort the results by created_at. sort: SORT_ORDER = REVERSE_CHRONOLOGICAL + # Filter by a specific tag name. + tags: [String!] + # Exclude comments ignored by the requesting user excludeIgnored: Boolean } @@ -278,7 +278,7 @@ input CommentCountQuery { author_id: ID # Filter by a specific tag name. - tag: [String] + tags: [String!] } type EditInfo { @@ -569,14 +569,22 @@ type Asset { # Returns recent comments recentComments: [Comment!] - # The top level comments that are attached to the asset. - comments(sort: SORT_ORDER = REVERSE_CHRONOLOGICAL, limit: Int = 10, excludeIgnored: Boolean): CommentConnection! + # The comments that are attached to the asset. + # If `deep` is true, it will return comments of all depths, + # otherwise only top-level comments are returned. + comments( + sort: SORT_ORDER = REVERSE_CHRONOLOGICAL, + limit: Int = 10, + excludeIgnored: Boolean, + tags: [String!] + deep: Boolean, + ): CommentConnection! # The count of top level comments on the asset. - commentCount(excludeIgnored: Boolean): Int + commentCount(excludeIgnored: Boolean, tags: [String!]): Int # The total count of all comments made on the asset. - totalCommentCount(excludeIgnored: Boolean): Int + totalCommentCount(excludeIgnored: Boolean, tags: [String!]): Int # The settings (rectified with the global settings) that should be applied to # this asset. diff --git a/locales/en.yml b/locales/en.yml index 9cf5296bd..af985b593 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -2,6 +2,7 @@ en: your_account_has_been_suspended: Your account has been temporarily suspended. your_account_has_been_banned: Your account has been banned. your_username_has_been_rejected: Your account has been suspended because your username has been deemed inappropriate. To restore your account please enter a new username. + embed_comments_tab: Comments bandialog: are_you_sure: "Are you sure you would like to ban {0}?" ban_user: "Ban User?" diff --git a/locales/es.yml b/locales/es.yml index 1bac18149..963723e9e 100644 --- a/locales/es.yml +++ b/locales/es.yml @@ -2,6 +2,7 @@ es: your_account_has_been_suspended: Su cuenta ha sido temporalmente suspendida. your_account_has_been_banned: Su cuenta ha sido suspendida. your_username_has_been_rejected: Su cuenta ha sido suspendida porque tu nombre de usuario ha sido considerado no apropiado para el espacio. Para recuperar la cuenta, por favor ingresar un nuevo nombre de usuario. + embed_comments_tab: Comentarios bandialog: are_you_sure: "¿Estás segura que quieres suspender a {0}?" ban_user: "¿Quieres suspender el Usuario?" diff --git a/package.json b/package.json index 382846cef..34e069c0f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "talk", - "version": "2.2.1", + "version": "2.4.0", "description": "A better commenting experience from Mozilla, The New York Times, and the Washington Post. https://coralproject.net", "main": "app.js", "scripts": { @@ -78,6 +78,7 @@ "express": "^4.15.2", "express-session": "^1.15.1", "form-data": "^2.1.2", + "fs-extra": "^3.0.1", "gql-merge": "^0.0.4", "graphql": "^0.9.1", "graphql-errors": "^2.1.0", diff --git a/plugins/coral-plugin-auth/client/components/styles.css b/plugins/coral-plugin-auth/client/components/styles.css index c8fbf9a30..735f34d31 100644 --- a/plugins/coral-plugin-auth/client/components/styles.css +++ b/plugins/coral-plugin-auth/client/components/styles.css @@ -66,7 +66,7 @@ input.error{ } .userBox { - padding: 10px 0 20px; + margin: 10px 0 20px; letter-spacing: 0.1px; } diff --git a/plugins/coral-plugin-like/client/LikeButton.js b/plugins/coral-plugin-like/client/LikeButton.js index 7d17ef056..dfbf88d1c 100644 --- a/plugins/coral-plugin-like/client/LikeButton.js +++ b/plugins/coral-plugin-like/client/LikeButton.js @@ -45,8 +45,10 @@ class LikeButton extends React.Component { className={cn(styles.button, {[styles.liked]: alreadyReacted}, `${plugin}-button`)} onClick={this.handleClick} > - {t(alreadyReacted ? 'coral-plugin-like.liked' : 'coral-plugin-like.like')} - + + {t(alreadyReacted ? 'coral-plugin-like.liked' : 'coral-plugin-like.like')} + + {count > 0 && count}
    diff --git a/plugins/coral-plugin-like/client/styles.css b/plugins/coral-plugin-like/client/styles.css index cb372fa47..9ce0d5271 100644 --- a/plugins/coral-plugin-like/client/styles.css +++ b/plugins/coral-plugin-like/client/styles.css @@ -23,3 +23,13 @@ } } } + +.icon { + padding: 0 2px; +} + +@media (max-width: 425px) { + .label { + display: none; + } +} diff --git a/plugins/coral-plugin-love/client/LoveButton.js b/plugins/coral-plugin-love/client/LoveButton.js index 7450ad668..62e5fe9d7 100644 --- a/plugins/coral-plugin-love/client/LoveButton.js +++ b/plugins/coral-plugin-love/client/LoveButton.js @@ -45,8 +45,10 @@ class LoveButton extends React.Component { className={cn(styles.button, {[styles.loved]: alreadyReacted}, `${plugin}-button`)} onClick={this.handleClick} > - {t(alreadyReacted ? 'coral-plugin-love.loved' : 'coral-plugin-love.love')} - + + {t(alreadyReacted ? 'coral-plugin-love.loved' : 'coral-plugin-love.love')} + + {count > 0 && count}
    diff --git a/plugins/coral-plugin-love/client/styles.css b/plugins/coral-plugin-love/client/styles.css index e16e17ca4..d7753a0e9 100644 --- a/plugins/coral-plugin-love/client/styles.css +++ b/plugins/coral-plugin-love/client/styles.css @@ -23,3 +23,13 @@ } } } + +.icon { + padding: 0 2px; +} + +@media (max-width: 425px) { + .label { + display: none; + } +} diff --git a/plugins/coral-plugin-respect/client/RespectButton.js b/plugins/coral-plugin-respect/client/RespectButton.js index 9bc556b67..878525051 100644 --- a/plugins/coral-plugin-respect/client/RespectButton.js +++ b/plugins/coral-plugin-respect/client/RespectButton.js @@ -45,11 +45,11 @@ class RespectButton extends React.Component { className={cn(styles.button, {[styles.respected]: alreadyReacted}, `${plugin}-button`)} onClick={this.handleClick} > - + {t(alreadyReacted ? 'coral-plugin-respect.respected' : 'coral-plugin-respect.respect')} - {count > 0 && count} + {count > 0 && count}
    ); diff --git a/plugins/coral-plugin-respect/client/styles.css b/plugins/coral-plugin-respect/client/styles.css index f0b780b86..3ab0fbd29 100644 --- a/plugins/coral-plugin-respect/client/styles.css +++ b/plugins/coral-plugin-respect/client/styles.css @@ -26,5 +26,11 @@ } .icon { - padding: 0 5px; + padding: 0 2px; +} + +@media (max-width: 425px) { + .label { + display: none; + } } diff --git a/plugins/coral-plugin-viewing-options/client/components/ViewingOptions.css b/plugins/coral-plugin-viewing-options/client/components/ViewingOptions.css index 47cda9a1a..9c302d07e 100644 --- a/plugins/coral-plugin-viewing-options/client/components/ViewingOptions.css +++ b/plugins/coral-plugin-viewing-options/client/components/ViewingOptions.css @@ -1,20 +1,17 @@ .root { - float: right; - text-align: right; - position: relative; - display: inline-block; - min-width: 220px; - z-index: 10; - position: relative; - cursor: pointer; +} + +.button { + composes: buttonReset from "coral-framework/styles/reset.css"; } .list { background: white; position: absolute; box-shadow: 0px 3px 5px 0px rgba(0,0,0,0.15); - right: 3px; + right: 0px; top: 20px; + z-index: 10; } .list > ul, .list > ul > li { @@ -25,4 +22,5 @@ .list > ul > li { padding: 10px; list-style: none; + white-space: nowrap; } diff --git a/plugins/coral-plugin-viewing-options/client/components/ViewingOptions.js b/plugins/coral-plugin-viewing-options/client/components/ViewingOptions.js index dc63a2373..b64123fdb 100644 --- a/plugins/coral-plugin-viewing-options/client/components/ViewingOptions.js +++ b/plugins/coral-plugin-viewing-options/client/components/ViewingOptions.js @@ -1,7 +1,7 @@ import React from 'react'; import cn from 'classnames'; import styles from './ViewingOptions.css'; -import {Slot} from 'plugin-api/beta/client/components'; +import {Slot, ClickOutside} from 'plugin-api/beta/client/components'; import {Icon} from 'plugin-api/beta/client/components/ui'; const ViewingOptions = (props) => { @@ -12,29 +12,38 @@ const ViewingOptions = (props) => { props.closeViewingOptions(); } }; + + const handleClickOutside = () => { + if (props.open) { + props.closeViewingOptions(); + } + }; + return ( -
    -
    - Viewing Options - {props.open ? : } - + +
    +
    + +
    + { + props.open ? ( +
    +
      + { + React.Children.map(, (component) => { + return React.createElement('li', { + className: 'coral-plugin-viewing-options-item' + }, component); + }) + } +
    +
    + ) : null + }
    - { - props.open ? ( -
    -
      - { - React.Children.map(, (component) => { - return React.createElement('li', { - className: 'coral-plugin-viewing-options-item' - }, component); - }) - } -
    -
    - ) : null - } -
    + ); }; diff --git a/plugins/coral-plugin-viewing-options/client/index.js b/plugins/coral-plugin-viewing-options/client/index.js index 519ce42aa..4e9001c1b 100644 --- a/plugins/coral-plugin-viewing-options/client/index.js +++ b/plugins/coral-plugin-viewing-options/client/index.js @@ -4,6 +4,6 @@ import reducer from './reducer'; export default { reducer, slots: { - streamBox: [ViewingOptions] + streamFilter: [ViewingOptions] } }; diff --git a/plugins/talk-plugin-featured/client/.babelrc b/plugins/talk-plugin-featured/client/.babelrc new file mode 100644 index 000000000..63b1c53de --- /dev/null +++ b/plugins/talk-plugin-featured/client/.babelrc @@ -0,0 +1,14 @@ +{ + "presets": [ + "es2015" + ], + "plugins": [ + "add-module-exports", + "transform-class-properties", + "transform-decorators-legacy", + "transform-object-assign", + "transform-object-rest-spread", + "transform-async-to-generator", + "transform-react-jsx" + ] +} diff --git a/plugins/talk-plugin-featured/client/.eslintrc.json b/plugins/talk-plugin-featured/client/.eslintrc.json new file mode 100644 index 000000000..9fe56bd14 --- /dev/null +++ b/plugins/talk-plugin-featured/client/.eslintrc.json @@ -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"] }] + } +} diff --git a/plugins/talk-plugin-featured/client/components/Tab.js b/plugins/talk-plugin-featured/client/components/Tab.js new file mode 100644 index 000000000..af11368b9 --- /dev/null +++ b/plugins/talk-plugin-featured/client/components/Tab.js @@ -0,0 +1,9 @@ +import React from 'react'; +import {TabCount} from 'plugin-api/beta/client/components/ui'; + +// TODO: This is just example code, and needs to replaced by an actual implementation. +export default ({active, asset: {recentComments}}) => ( + + Featured {recentComments.length} + +); diff --git a/plugins/talk-plugin-featured/client/components/TabPane.js b/plugins/talk-plugin-featured/client/components/TabPane.js new file mode 100644 index 000000000..107c806b4 --- /dev/null +++ b/plugins/talk-plugin-featured/client/components/TabPane.js @@ -0,0 +1,16 @@ +import React from 'react'; + +// TODO: This is just example code, and needs to replaced by an actual implementation. +export default ({asset: {recentComments}}) => ( +
    + {recentComments.map((comment) => ( +
    +
    {comment.user.username}
    +
    + {comment.body} +
    +
    +
    + ))} +
    +); diff --git a/plugins/talk-plugin-featured/client/containers/Tab.js b/plugins/talk-plugin-featured/client/containers/Tab.js new file mode 100644 index 000000000..0bcb49e13 --- /dev/null +++ b/plugins/talk-plugin-featured/client/containers/Tab.js @@ -0,0 +1,17 @@ +import {compose, gql} from 'react-apollo'; +import withFragments from 'coral-framework/hocs/withFragments'; +import Tab from '../components/Tab'; + +// TODO: This is just example code, and needs to replaced by an actual implementation. +const enhance = compose( + withFragments({ + asset: gql` + fragment TalkFeatured_Tab_asset on Asset { + recentComments { + id + } + }`, + }), +); + +export default enhance(Tab); diff --git a/plugins/talk-plugin-featured/client/containers/TabPane.js b/plugins/talk-plugin-featured/client/containers/TabPane.js new file mode 100644 index 000000000..19c4d855d --- /dev/null +++ b/plugins/talk-plugin-featured/client/containers/TabPane.js @@ -0,0 +1,22 @@ +import {compose, gql} from 'react-apollo'; +import withFragments from 'coral-framework/hocs/withFragments'; +import TabPane from '../components/TabPane'; + +// TODO: This is just example code, and needs to replaced by an actual implementation. +const enhance = compose( + withFragments({ + asset: gql` + fragment TalkFeatured_TabPane_asset on Asset { + recentComments { + id + body + user { + id + username + } + } + }`, + }), +); + +export default enhance(TabPane); diff --git a/plugins/talk-plugin-featured/client/index.js b/plugins/talk-plugin-featured/client/index.js new file mode 100644 index 000000000..69dfaa6cb --- /dev/null +++ b/plugins/talk-plugin-featured/client/index.js @@ -0,0 +1,9 @@ +import Tab from './containers/Tab'; +import TabPane from './containers/TabPane'; + +export default { + slots: { + streamTabs: [Tab], + streamTabPanes: [TabPane], + } +}; diff --git a/plugins/talk-plugin-featured/index.js b/plugins/talk-plugin-featured/index.js new file mode 100644 index 000000000..85dfb349b --- /dev/null +++ b/plugins/talk-plugin-featured/index.js @@ -0,0 +1,2 @@ +module.exports = {}; + diff --git a/plugins/talk-plugin-permalink/client/components/styles.css b/plugins/talk-plugin-permalink/client/components/styles.css index f14e87881..244cc2acc 100644 --- a/plugins/talk-plugin-permalink/client/components/styles.css +++ b/plugins/talk-plugin-permalink/client/components/styles.css @@ -42,8 +42,8 @@ } .button { + composes: buttonReset from "coral-framework/styles/reset.css"; margin: 5px 0px 5px 10px; - cursor: pointer; } .copyButton { diff --git a/test/helpers/redis.js b/test/helpers/redis.js new file mode 100644 index 000000000..02ab81b51 --- /dev/null +++ b/test/helpers/redis.js @@ -0,0 +1,15 @@ +const {createClient} = require('../../services/redis'); + +// Create a redis client to use for clearing the database. +const client = createClient(); + +module.exports.clearDB = () => + new Promise((resolve, reject) => + client.flushdb((err) => { + if (err) { + return reject(err); + } + + return resolve(); + }) + ); diff --git a/test/server/redis.js b/test/server/redis.js new file mode 100644 index 000000000..529548918 --- /dev/null +++ b/test/server/redis.js @@ -0,0 +1,3 @@ +const redis = require('../helpers/redis'); + +beforeEach(() => redis.clearDB()); diff --git a/views/embed/stream.ejs b/views/embed/stream.ejs index 03897d85f..99ca2072a 100644 --- a/views/embed/stream.ejs +++ b/views/embed/stream.ejs @@ -15,7 +15,7 @@ -
    +
    diff --git a/yarn.lock b/yarn.lock index 9de2c35fa..c536e2901 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3290,6 +3290,14 @@ fs-extra@^0.26.4: path-is-absolute "^1.0.0" rimraf "^2.2.8" +fs-extra@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-3.0.1.tgz#3794f378c58b342ea7dbbb23095109c4b3b62291" + dependencies: + graceful-fs "^4.1.2" + jsonfile "^3.0.0" + universalify "^0.1.0" + fs-promise@^0.3.1: version "0.3.1" resolved "https://registry.yarnpkg.com/fs-promise/-/fs-promise-0.3.1.tgz#bf34050368f24d6dc9dfc6688ab5cead8f86842a" @@ -4614,6 +4622,10 @@ json-stable-stringify@^1.0.0, json-stable-stringify@^1.0.1: dependencies: jsonify "~0.0.0" +json-stringify-pretty-compact@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/json-stringify-pretty-compact/-/json-stringify-pretty-compact-1.0.4.tgz#d5161131be27fd9748391360597fcca250c6c5ce" + json-stringify-safe@~5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz#1296a2d58fd45f19a0f6ce01d65701e2c735b6eb" @@ -4632,6 +4644,12 @@ jsonfile@^2.1.0: optionalDependencies: graceful-fs "^4.1.6" +jsonfile@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-3.0.1.tgz#a5ecc6f65f53f662c4415c7675a0331d0992ec66" + optionalDependencies: + graceful-fs "^4.1.6" + jsonify@~0.0.0: version "0.0.0" resolved "https://registry.yarnpkg.com/jsonify/-/jsonify-0.0.0.tgz#2c74b6ee41d93ca51b7b5aaee8f503631d252a73" @@ -8263,6 +8281,10 @@ uniqs@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/uniqs/-/uniqs-2.0.0.tgz#ffede4b36b25290696e6e165d4a59edb998e6b02" +universalify@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.1.0.tgz#9eb1c4651debcc670cc94f1a75762332bb967778" + unpipe@1.0.0, unpipe@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec"