mirror of
https://github.com/wassname/talk.git
synced 2026-09-14 11:36:51 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1c23f72807 | ||
|
|
8c1eaf09bc | ||
|
|
6e82c180f7 | ||
|
|
81f3cc808c | ||
|
|
55928dff08 | ||
|
|
9695db0678 | ||
|
|
655e53a35c | ||
|
|
d61ede2396 | ||
|
|
a40dd04c1b | ||
|
|
9d25a4c14a | ||
|
|
801d0a92af | ||
|
|
8bf86f34ee | ||
|
|
14a35033da | ||
|
|
05da69d097 | ||
|
|
ffce78ef60 | ||
|
|
6561f733b7 | ||
|
|
b9758ba942 | ||
|
|
4e56406c37 | ||
|
|
06858bf071 | ||
|
|
66e4f02a1a | ||
|
|
c3d76c6a4f | ||
|
|
c8534a91a6 | ||
|
|
87ddfea689 | ||
|
|
830441c531 | ||
|
|
7417d51973 | ||
|
|
847a30ff92 | ||
|
|
9f4df35c3a | ||
|
|
f6ad9c9373 | ||
|
|
8872248d63 | ||
|
|
d780eea357 | ||
|
|
20aba92ad2 | ||
|
|
7a2fd95eca | ||
|
|
d7dfae89a3 | ||
|
|
fa33fe2ed2 | ||
|
|
456eedfd31 | ||
|
|
4725f07757 | ||
|
|
e24f6ca9f1 | ||
|
|
ceb99cf205 | ||
|
|
e47d3b085e | ||
|
|
5e1fcc48e1 | ||
|
|
b5c9e92cc6 | ||
|
|
71ff1c31f8 | ||
|
|
cc76517ba8 | ||
|
|
6f909c3451 | ||
|
|
2202c8ae5b | ||
|
|
6d8133b1b5 | ||
|
|
2384ae4baa | ||
|
|
1c7ad9a8ac | ||
|
|
c3360aa020 | ||
|
|
4aa7900769 | ||
|
|
0dc61f33f5 | ||
|
|
8af0d929c2 | ||
|
|
4e19d5eecf | ||
|
|
5465563856 | ||
|
|
b5a64bd2f9 | ||
|
|
b02782d532 | ||
|
|
7cb05cebef | ||
|
|
80d4ffa5bf | ||
|
|
9963f6d337 | ||
|
|
eff923424c | ||
|
|
c3e32f37dc | ||
|
|
18f11a84cf | ||
|
|
b0bdfff785 | ||
|
|
dc55febac8 | ||
|
|
4d6e02228c | ||
|
|
c50fe9f91d | ||
|
|
7d5a73bc95 | ||
|
|
5f3220dba9 | ||
|
|
700ab38cb0 | ||
|
|
6fcd003e76 | ||
|
|
0069d17672 | ||
|
|
afdcdff51e | ||
|
|
3d8478dfb2 | ||
|
|
71d2f44d38 | ||
|
|
124ea19b52 | ||
|
|
512a463589 | ||
|
|
e9cb61a534 | ||
|
|
a6db60096c | ||
|
|
9364272ffc | ||
|
|
1ceb72ca5c | ||
|
|
9e001f5ce9 | ||
|
|
0fcdd09f9f | ||
|
|
3ad3b3af1c | ||
|
|
d3f7e6210c | ||
|
|
447ec7e524 | ||
|
|
f8ca14d3d3 | ||
|
|
5faa92e8b3 | ||
|
|
17783c91c4 | ||
|
|
ad4c4a0a5b | ||
|
|
7956c193c5 | ||
|
|
2aa3b7a5a0 | ||
|
|
2f77842072 | ||
|
|
a595e94394 | ||
|
|
d03647067d | ||
|
|
fb537c9990 | ||
|
|
8ea7ba9b69 | ||
|
|
15297cd674 | ||
|
|
e46802910c | ||
|
|
19b90910ba | ||
|
|
291cee3af4 | ||
|
|
069c4c5e50 | ||
|
|
7d54fb60a8 | ||
|
|
e8143aea14 | ||
|
|
9e1d010db3 | ||
|
|
728d523b9e | ||
|
|
5838f5d486 | ||
|
|
1c8e1cc46c | ||
|
|
c4a41af33b | ||
|
|
5e463c00fd | ||
|
|
933a9371f3 | ||
|
|
0be77fa5fd | ||
|
|
49eecaa65b | ||
|
|
39d8a5d01b | ||
|
|
414e5d78bd | ||
|
|
e9a6c3dfe3 | ||
|
|
3b7603ee31 | ||
|
|
8c89296007 | ||
|
|
32fe291847 | ||
|
|
056ddc7010 | ||
|
|
2d0eec15c1 | ||
|
|
2be73d1df2 | ||
|
|
3ad17f008f | ||
|
|
547547d04c | ||
|
|
90d7e3706c | ||
|
|
9993350b7e | ||
|
|
7c66d846c2 | ||
|
|
6b0cdae183 | ||
|
|
c20df65ebc | ||
|
|
7efdccb683 | ||
|
|
a9796d47cd | ||
|
|
7c145bbb3c | ||
|
|
ac9e70bc67 | ||
|
|
defd28b20f | ||
|
|
b05b54a991 | ||
|
|
3c96b7eb1e | ||
|
|
6e483842f1 | ||
|
|
0276c00828 | ||
|
|
c6efc586ae | ||
|
|
6de9c69668 | ||
|
|
396abaae22 | ||
|
|
e98b9ff9bc | ||
|
|
cbf83206b8 | ||
|
|
664ace170b | ||
|
|
bd60eca6f9 | ||
|
|
e097b8eb21 | ||
|
|
058cab970b | ||
|
|
22c2843ae5 | ||
|
|
d8cb560755 | ||
|
|
ea3d8e141d | ||
|
|
cf50a0e85c | ||
|
|
b9b62e5764 | ||
|
|
eadbb03c15 | ||
|
|
b6dbb5975f | ||
|
|
fd1cf8121e | ||
|
|
dcbd0fc167 | ||
|
|
44d82f8b13 | ||
|
|
27e6372fe2 | ||
|
|
686dc96c5f | ||
|
|
8d25b766ff | ||
|
|
555d54425a | ||
|
|
f3f7fad68f | ||
|
|
9483922781 | ||
|
|
91d415b434 | ||
|
|
1943b38544 | ||
|
|
9344490514 | ||
|
|
1bf2053cb8 | ||
|
|
9fe7b961ca | ||
|
|
ac0db69c27 | ||
|
|
d981b5a85b | ||
|
|
276b2c7591 | ||
|
|
a126c94aa4 | ||
|
|
576cbed8e9 | ||
|
|
bf15354aa2 | ||
|
|
6e458e0149 | ||
|
|
259ce9c9fb | ||
|
|
6599ed2b4c | ||
|
|
29753a958b | ||
|
|
77bfd55af3 | ||
|
|
09bd0a6735 |
@@ -10,4 +10,5 @@ plugins/*
|
||||
!plugins/coral-plugin-like
|
||||
!plugins/coral-plugin-mod
|
||||
!plugins/coral-plugin-love
|
||||
!plugins/coral-plugin-viewing-options
|
||||
node_modules
|
||||
|
||||
@@ -23,5 +23,6 @@ plugins/*
|
||||
!plugins/coral-plugin-like
|
||||
!plugins/coral-plugin-mod
|
||||
!plugins/coral-plugin-love
|
||||
!plugins/coral-plugin-viewing-options
|
||||
|
||||
**/node_modules/*
|
||||
|
||||
+35
@@ -280,6 +280,41 @@ send data to the client. If the type in question contains args, clients may subs
|
||||
|
||||
For more information, see the [Apollo Docs](https://github.com/apollographql/graphql-subscriptions).
|
||||
|
||||
#### Field: `tokenUserNotFound`
|
||||
|
||||
```js
|
||||
tokenUserNotFound: async ({jwt, token}) => {
|
||||
let profile = await someExternalService(token);
|
||||
if (!profile) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let user = await UserModel.findOneAndUpdate({
|
||||
id: profile.id
|
||||
}, {
|
||||
id: profile.id,
|
||||
username: profile.username,
|
||||
lowercaseUsername: profile.username.toLowerCase(),
|
||||
roles: [],
|
||||
profiles: []
|
||||
}, {
|
||||
setDefaultsOnInsert: true,
|
||||
new: true,
|
||||
upsert: true
|
||||
});
|
||||
|
||||
return user;
|
||||
}
|
||||
```
|
||||
|
||||
The `tokenUserNotFound` hook allows auth integrations to hook into the event
|
||||
when a valid token is provided but a user can't be found in the database that
|
||||
matches the provided id.
|
||||
|
||||
The function is async, and should return the user object that was created in the
|
||||
database, or null if the user wasn't found. The `jwt` paramenter of the object
|
||||
is the unpacked token, while `token` is the original jwt token string.
|
||||
|
||||
#### Field: `router`
|
||||
|
||||
```js
|
||||
|
||||
@@ -6,6 +6,7 @@ const helmet = require('helmet');
|
||||
const authentication = require('./middleware/authentication');
|
||||
const {passport} = require('./services/passport');
|
||||
const plugins = require('./services/plugins');
|
||||
const i18n = require('./services/i18n');
|
||||
const enabled = require('debug').enabled;
|
||||
const errors = require('./errors');
|
||||
const {createGraphOptions} = require('./graph');
|
||||
@@ -157,6 +158,8 @@ app.use('/', (err, req, res, next) => {
|
||||
console.error(err);
|
||||
}
|
||||
|
||||
i18n.init(req);
|
||||
|
||||
if (err instanceof errors.APIError) {
|
||||
res.status(err.status);
|
||||
res.render('error', {
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
const program = require('./commander');
|
||||
const parseDuration = require('parse-duration');
|
||||
const parseDuration = require('ms');
|
||||
const Table = require('cli-table');
|
||||
const AssetModel = require('../models/asset');
|
||||
const mongoose = require('../services/mongoose');
|
||||
|
||||
@@ -4,21 +4,20 @@
|
||||
"es6": true,
|
||||
"mocha": true
|
||||
},
|
||||
"extends": "../.eslintrc.json",
|
||||
"parserOptions": {
|
||||
"sourceType": "module",
|
||||
"ecmaFeatures": {
|
||||
"experimentalObjectRestSpread": true,
|
||||
"jsx": true
|
||||
},
|
||||
"sourceType": "module"
|
||||
}
|
||||
},
|
||||
"parser": "babel-eslint",
|
||||
"plugins": [
|
||||
"react"
|
||||
],
|
||||
"rules": {
|
||||
"react/jsx-uses-react": "error",
|
||||
"react/jsx-uses-vars": "error",
|
||||
"no-console": ["warn", { "allow": ["warn", "error"] }]
|
||||
"react/jsx-uses-react": "error",
|
||||
"react/jsx-uses-vars": "error",
|
||||
"no-console": ["warn", { "allow": ["warn", "error"] }]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,3 +33,21 @@ export const setSortOrder = (order) => ({
|
||||
order
|
||||
});
|
||||
|
||||
export const changeUserDetailStatuses = (tab) => {
|
||||
let statuses;
|
||||
if (tab === 'all') {
|
||||
statuses = ['NONE', 'ACCEPTED', 'REJECTED', 'PREMOD'];
|
||||
} else if (tab === 'rejected') {
|
||||
statuses = ['REJECTED'];
|
||||
}
|
||||
return {type: actions.CHANGE_USER_DETAIL_STATUSES, tab, statuses};
|
||||
};
|
||||
|
||||
export const clearUserDetailSelections = () => ({type: actions.CLEAR_USER_DETAIL_SELECTIONS});
|
||||
|
||||
export const toggleSelectCommentInUserDetail = (id, active) => {
|
||||
return {
|
||||
type: active ? actions.SELECT_USER_DETAIL_COMMENT : actions.UNSELECT_USER_DETAIL_COMMENT,
|
||||
id
|
||||
};
|
||||
};
|
||||
|
||||
@@ -17,11 +17,11 @@ const ActionButton = ({type = '', active, ...props}) => {
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={`${typeName} ${styles.actionButton} ${active ? styles[`${typeName}__active`] : ''}`}
|
||||
className={`${typeName} ${styles.actionButton} ${props.minimal ? styles.minimal : ''} ${active ? styles[`${typeName}__active`] : ''}`}
|
||||
cStyle={typeName}
|
||||
icon={menuActionsMap[type].icon}
|
||||
onClick={type === 'APPROVE' ? props.acceptComment : props.rejectComment}
|
||||
>{t(`modqueue.${text}`)}</Button>
|
||||
>{props.minimal ? '' : t(`modqueue.${text}`)}</Button>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -189,6 +189,12 @@
|
||||
width: 140px;
|
||||
}
|
||||
|
||||
.minimal {
|
||||
width: 45px;
|
||||
min-width: 0;
|
||||
float: left;
|
||||
}
|
||||
|
||||
.approve__active {
|
||||
box-shadow: none;
|
||||
color: white;
|
||||
|
||||
@@ -8,3 +8,7 @@ export const HIDE_SUSPEND_USER_DIALOG = 'HIDE_SUSPEND_USER_DIALOG';
|
||||
export const VIEW_USER_DETAIL = 'VIEW_USER_DETAIL';
|
||||
export const HIDE_USER_DETAIL = 'HIDE_USER_DETAIL';
|
||||
export const SET_SORT_ORDER = 'MODERATION_SET_SORT_ORDER';
|
||||
export const CHANGE_USER_DETAIL_STATUSES = 'CHANGE_USER_DETAIL_STATUSES';
|
||||
export const SELECT_USER_DETAIL_COMMENT = 'SELECT_USER_DETAIL_COMMENT';
|
||||
export const UNSELECT_USER_DETAIL_COMMENT = 'UNSELECT_USER_DETAIL_COMMENT';
|
||||
export const CLEAR_USER_DETAIL_SELECTIONS = 'CLEAR_USER_DETAIL_SELECTIONS';
|
||||
|
||||
@@ -9,9 +9,10 @@ import App from './components/App';
|
||||
|
||||
import 'react-mdl/extra/material.js';
|
||||
import './graphql';
|
||||
import {loadPluginsTranslations} from 'coral-framework/helpers/plugins';
|
||||
import {loadPluginsTranslations, injectPluginsReducers} from 'coral-framework/helpers/plugins';
|
||||
|
||||
loadPluginsTranslations();
|
||||
injectPluginsReducers();
|
||||
|
||||
render(
|
||||
<ApolloProvider client={client} store={store}>
|
||||
|
||||
@@ -13,5 +13,5 @@ export default {
|
||||
community,
|
||||
moderation,
|
||||
install,
|
||||
config
|
||||
config,
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {fromJS, Map} from 'immutable';
|
||||
import {fromJS, Map, Set} from 'immutable';
|
||||
import * as actions from '../constants/moderation';
|
||||
|
||||
const initialState = fromJS({
|
||||
@@ -8,6 +8,9 @@ const initialState = fromJS({
|
||||
commentId: null,
|
||||
commentStatus: null,
|
||||
userDetailId: null,
|
||||
userDetailActiveTab: 'all',
|
||||
userDetailStatuses: ['NONE', 'ACCEPTED', 'REJECTED', 'PREMOD'],
|
||||
userDetailSelectedIds: new Set(),
|
||||
banDialog: false,
|
||||
shortcutsNoteVisible: window.localStorage.getItem('coral:shortcutsNote') || 'show',
|
||||
sortOrder: 'REVERSE_CHRONOLOGICAL',
|
||||
@@ -64,7 +67,19 @@ export default function moderation (state = initialState, action) {
|
||||
case actions.VIEW_USER_DETAIL:
|
||||
return state.set('userDetailId', action.userId);
|
||||
case actions.HIDE_USER_DETAIL:
|
||||
return state.set('userDetailId', null);
|
||||
return state
|
||||
.set('userDetailId', null)
|
||||
.update('userDetailSelectedIds', (set) => set.clear());
|
||||
case actions.CLEAR_USER_DETAIL_SELECTIONS:
|
||||
return state.update('userDetailSelectedIds', (set) => set.clear());
|
||||
case actions.CHANGE_USER_DETAIL_STATUSES:
|
||||
return state
|
||||
.set('userDetailActiveTab', action.tab)
|
||||
.set('userDetailStatuses', action.statuses);
|
||||
case actions.SELECT_USER_DETAIL_COMMENT:
|
||||
return state.update('userDetailSelectedIds', (set) => set.add(action.id));
|
||||
case actions.UNSELECT_USER_DETAIL_COMMENT:
|
||||
return state.update('userDetailSelectedIds', (set) => set.delete(action.id));
|
||||
case actions.SET_SORT_ORDER:
|
||||
return state.set('sortOrder', action.order);
|
||||
default :
|
||||
|
||||
@@ -99,7 +99,7 @@
|
||||
.inlineTextfield {
|
||||
border-color: #ccc;
|
||||
border-style: solid;
|
||||
border-width: 0px 0px 1px 0px;
|
||||
border-width: 0px 0px 1px 0px;
|
||||
text-align: center;
|
||||
font-size: inherit;
|
||||
}
|
||||
@@ -108,7 +108,7 @@
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.charCountTexfield {
|
||||
.charCountTexfield, .editCommentTimeframeTextfield {
|
||||
width: 4em;
|
||||
padding: 0px;
|
||||
}
|
||||
|
||||
@@ -25,12 +25,6 @@ const ModerationSettings = ({settings, updateSettings, onChangeWordlist}) => {
|
||||
const on = styles.enabledSetting;
|
||||
const off = styles.disabledSetting;
|
||||
|
||||
const onChangeEditCommentWindowLength = (e) => {
|
||||
const value = e.target.value;
|
||||
const valueAsNumber = parseFloat(value);
|
||||
const milliseconds = (!isNaN(valueAsNumber)) && (valueAsNumber * 1000);
|
||||
updateSettings({editCommentWindowLength: milliseconds || value});
|
||||
};
|
||||
return (
|
||||
<div className={styles.Configure}>
|
||||
<Card className={`${styles.configSetting} ${settings.requireEmailConfirmation ? on : off}`}>
|
||||
@@ -76,27 +70,6 @@ const ModerationSettings = ({settings, updateSettings, onChangeWordlist}) => {
|
||||
bannedWords={settings.wordlist.banned}
|
||||
suspectWords={settings.wordlist.suspect}
|
||||
onChangeWordlist={onChangeWordlist} />
|
||||
|
||||
{/* Edit Comment Timeframe */}
|
||||
<Card className={styles.configSetting}>
|
||||
<div className={styles.settingsHeader}>{t('configure.edit_comment_timeframe_heading')}</div>
|
||||
<p>
|
||||
{t('configure.edit_comment_timeframe_text_pre')}
|
||||
|
||||
<input
|
||||
style={{width: '3em'}}
|
||||
className={styles.inlineTextfield}
|
||||
type="number"
|
||||
min="0"
|
||||
onChange={onChangeEditCommentWindowLength}
|
||||
placeholder="30"
|
||||
defaultValue={(settings.editCommentWindowLength / 1000) /* saved as ms, rendered as seconds */}
|
||||
pattern='[0-9]+([\.][0-9]*)?'
|
||||
/>
|
||||
|
||||
{t('configure.edit_comment_timeframe_text_post')}
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -61,6 +61,13 @@ const updateClosedTimeout = (updateSettings, ts, isMeasure) => (event) => {
|
||||
}
|
||||
};
|
||||
|
||||
const updateEditCommentWindowLength = (updateSettings) => (e) => {
|
||||
const value = e.target.value;
|
||||
const valueAsNumber = parseFloat(value);
|
||||
const milliseconds = (!isNaN(valueAsNumber)) && (valueAsNumber * 1000);
|
||||
updateSettings({editCommentWindowLength: milliseconds || value});
|
||||
};
|
||||
|
||||
const StreamSettings = ({updateSettings, settingsError, settings, errors}) => {
|
||||
|
||||
// just putting this here for shorthand below
|
||||
@@ -132,6 +139,25 @@ const StreamSettings = ({updateSettings, settingsError, settings, errors}) => {
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
{/* Edit Comment Timeframe */}
|
||||
<Card className={styles.configSetting}>
|
||||
<div className={styles.settingsHeader}>{t('configure.edit_comment_timeframe_heading')}</div>
|
||||
<p>
|
||||
{t('configure.edit_comment_timeframe_text_pre')}
|
||||
|
||||
<input
|
||||
className={`${styles.inlineTextfield} ${styles.editCommentTimeframeTextfield}`}
|
||||
type="number"
|
||||
min="0"
|
||||
onChange={updateEditCommentWindowLength(updateSettings)}
|
||||
placeholder="30"
|
||||
defaultValue={(settings.editCommentWindowLength / 1000) /* saved as ms, rendered as seconds */}
|
||||
pattern='[0-9]+([\.][0-9]*)?'
|
||||
/>
|
||||
|
||||
{t('configure.edit_comment_timeframe_text_post')}
|
||||
</p>
|
||||
</Card>
|
||||
<Card className={`${styles.configSetting} ${styles.configSettingInfoBox}`}>
|
||||
<div className={styles.action}>
|
||||
<Checkbox
|
||||
|
||||
@@ -23,6 +23,9 @@ const Comment = ({
|
||||
viewUserDetail,
|
||||
suspectWords,
|
||||
bannedWords,
|
||||
minimal,
|
||||
selected,
|
||||
toggleSelect,
|
||||
...props
|
||||
}) => {
|
||||
const links = linkify.getMatches(comment.body);
|
||||
@@ -47,17 +50,38 @@ const Comment = ({
|
||||
})
|
||||
.concat(linkText);
|
||||
|
||||
let selectionStateCSS;
|
||||
if (minimal) {
|
||||
selectionStateCSS = selected ? styles.minimalSelection : '';
|
||||
} else {
|
||||
selectionStateCSS = selected ? 'mdl-shadow--16dp' : 'mdl-shadow--2dp';
|
||||
}
|
||||
|
||||
return (
|
||||
<li
|
||||
tabIndex={props.index}
|
||||
className={`mdl-card ${props.selected ? 'mdl-shadow--16dp' : 'mdl-shadow--2dp'} ${styles.Comment} ${styles.listItem} ${props.selected ? styles.selected : ''}`}
|
||||
className={`mdl-card ${selectionStateCSS} ${styles.Comment} ${styles.listItem} ${minimal ? styles.minimal : ''}`}
|
||||
>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.itemHeader}>
|
||||
<div className={styles.author}>
|
||||
<span className={styles.username} onClick={() => viewUserDetail(comment.user.id)}>
|
||||
{comment.user.name}
|
||||
</span>
|
||||
{
|
||||
!minimal && (
|
||||
<span className={styles.username} onClick={() => viewUserDetail(comment.user.id)}>
|
||||
{comment.user.name}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
{
|
||||
minimal && typeof selected === 'boolean' && typeof toggleSelect === 'function' && (
|
||||
<input
|
||||
className={styles.bulkSelectInput}
|
||||
type='checkbox'
|
||||
value={comment.id}
|
||||
checked={selected}
|
||||
onChange={(e) => toggleSelect(e.target.value, e.target.checked)} />
|
||||
)
|
||||
}
|
||||
<span className={styles.created}>
|
||||
{timeago(comment.created_at || Date.now() - props.index * 60 * 1000)}
|
||||
</span>
|
||||
@@ -128,6 +152,7 @@ const Comment = ({
|
||||
(action === 'APPROVE' && comment.status === 'ACCEPTED');
|
||||
return (
|
||||
<ActionButton
|
||||
minimal={minimal}
|
||||
key={i}
|
||||
type={action}
|
||||
user={comment.user}
|
||||
@@ -154,14 +179,12 @@ const Comment = ({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Slot
|
||||
data={props.data}
|
||||
root={props.root}
|
||||
fill="adminCommentDetailArea"
|
||||
comment={comment}
|
||||
/>
|
||||
</div>
|
||||
<Slot
|
||||
data={props.data}
|
||||
root={props.root}
|
||||
fill="adminCommentDetailArea"
|
||||
comment={comment}
|
||||
/>
|
||||
{flagActions && flagActions.length
|
||||
? <FlagBox
|
||||
actions={flagActions}
|
||||
@@ -173,6 +196,7 @@ const Comment = ({
|
||||
};
|
||||
|
||||
Comment.propTypes = {
|
||||
minimal: PropTypes.bool,
|
||||
viewUserDetail: PropTypes.func.isRequired,
|
||||
acceptComment: PropTypes.func.isRequired,
|
||||
rejectComment: PropTypes.func.isRequired,
|
||||
@@ -182,6 +206,7 @@ Comment.propTypes = {
|
||||
showBanUserDialog: PropTypes.func.isRequired,
|
||||
showSuspendUserDialog: PropTypes.func.isRequired,
|
||||
currentUserId: PropTypes.string.isRequired,
|
||||
toggleSelect: PropTypes.func,
|
||||
comment: PropTypes.shape({
|
||||
body: PropTypes.string.isRequired,
|
||||
action_summaries: PropTypes.array,
|
||||
|
||||
@@ -187,10 +187,15 @@ export default class Moderation extends Component {
|
||||
{moderation.userDetailId && (
|
||||
<UserDetail
|
||||
id={moderation.userDetailId}
|
||||
hideUserDetail={hideUserDetail} />
|
||||
hideUserDetail={hideUserDetail}
|
||||
bannedWords={settings.wordlist.banned}
|
||||
suspectWords={settings.wordlist.suspect}
|
||||
showBanUserDialog={props.showBanUserDialog}
|
||||
showSuspendUserDialog={props.showSuspendUserDialog}
|
||||
acceptComment={props.acceptComment}
|
||||
rejectComment={props.rejectComment} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -36,6 +36,47 @@
|
||||
background-color: transparent;
|
||||
font-size: 16px;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
width: 90%;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.commentStatuses {
|
||||
padding: 10px 0 0 0;
|
||||
margin: 0;
|
||||
height: 52px;
|
||||
list-style: none;
|
||||
box-sizing: border-box;
|
||||
|
||||
li {
|
||||
display: inline-block;
|
||||
margin: 0 10px;
|
||||
cursor: pointer;
|
||||
padding: 0 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.active {
|
||||
font-weight: bold;
|
||||
border-bottom: 3px solid #F36451;
|
||||
}
|
||||
|
||||
.bulkActionGroup {
|
||||
height: 52px;
|
||||
background-color: #efefef;
|
||||
|
||||
i {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.bulkAction {
|
||||
display: inline-block;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
transform: scale(.7);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.bulkAction:last-child {
|
||||
margin-left: -10px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,12 +2,24 @@ import React, {PropTypes} from 'react';
|
||||
import {Button, Drawer} from 'coral-ui';
|
||||
import styles from './UserDetail.css';
|
||||
import Slot from 'coral-framework/components/Slot';
|
||||
import Comment from './Comment';
|
||||
import {actionsMap} from '../helpers/moderationQueueActionsMap';
|
||||
|
||||
export default class UserDetail extends React.Component {
|
||||
static propTypes = {
|
||||
id: PropTypes.string.isRequired,
|
||||
hideUserDetail: PropTypes.func.isRequired,
|
||||
root: PropTypes.object.isRequired,
|
||||
bannedWords: PropTypes.array.isRequired,
|
||||
suspectWords: PropTypes.array.isRequired,
|
||||
showBanUserDialog: PropTypes.func.isRequired,
|
||||
showSuspendUserDialog: PropTypes.func.isRequired,
|
||||
acceptComment: PropTypes.func.isRequired,
|
||||
rejectComment: PropTypes.func.isRequired,
|
||||
changeStatus: PropTypes.func.isRequired,
|
||||
toggleSelect: PropTypes.func.isRequired,
|
||||
bulkAccept: PropTypes.func.isRequired,
|
||||
bulkReject: PropTypes.func.isRequired,
|
||||
}
|
||||
|
||||
copyPermalink = () => {
|
||||
@@ -20,15 +32,55 @@ export default class UserDetail extends React.Component {
|
||||
}
|
||||
}
|
||||
|
||||
rejectThenReload = (info) => {
|
||||
this.props.rejectComment(info).then(() => {
|
||||
this.props.data.refetch();
|
||||
});
|
||||
}
|
||||
|
||||
acceptThenReload = (info) => {
|
||||
this.props.acceptComment(info).then(() => {
|
||||
this.props.data.refetch();
|
||||
});
|
||||
}
|
||||
|
||||
showAll = () => {
|
||||
this.props.changeStatus('all');
|
||||
}
|
||||
|
||||
showRejected = () => {
|
||||
this.props.changeStatus('rejected');
|
||||
}
|
||||
|
||||
render () {
|
||||
const {root: {user, totalComments, rejectedComments}, hideUserDetail} = this.props;
|
||||
const {
|
||||
root: {
|
||||
user,
|
||||
totalComments,
|
||||
rejectedComments,
|
||||
comments: {nodes}
|
||||
},
|
||||
moderation: {
|
||||
userDetailActiveTab: tab,
|
||||
userDetailSelectedIds: selectedIds
|
||||
},
|
||||
bannedWords,
|
||||
suspectWords,
|
||||
toggleSelect,
|
||||
bulkAccept,
|
||||
bulkReject,
|
||||
showBanUserDialog,
|
||||
showSuspendUserDialog,
|
||||
hideUserDetail
|
||||
} = this.props;
|
||||
const localProfile = user.profiles.find((p) => p.provider === 'local');
|
||||
|
||||
let profile;
|
||||
if (localProfile) {
|
||||
profile = localProfile.id;
|
||||
}
|
||||
|
||||
let rejectedPercent = rejectedComments / totalComments;
|
||||
let rejectedPercent = (rejectedComments / totalComments) * 100;
|
||||
if (rejectedPercent === Infinity || isNaN(rejectedPercent)) {
|
||||
|
||||
// if totalComments is 0, you're dividing by zero, which is naughty
|
||||
@@ -62,8 +114,60 @@ export default class UserDetail extends React.Component {
|
||||
<p>{`${(rejectedPercent).toFixed(1)}%`}</p>
|
||||
</div>
|
||||
</div>
|
||||
{
|
||||
selectedIds.length === 0
|
||||
? (
|
||||
<ul className={styles.commentStatuses}>
|
||||
<li className={tab === 'all' ? styles.active : ''} onClick={this.showAll}>All</li>
|
||||
<li className={tab === 'rejected' ? styles.active : ''} onClick={this.showRejected}>Rejected</li>
|
||||
</ul>
|
||||
)
|
||||
: (
|
||||
<div className={styles.bulkActionGroup}>
|
||||
<Button
|
||||
onClick={bulkAccept}
|
||||
className={styles.bulkAction}
|
||||
cStyle='approve'
|
||||
icon='done'>
|
||||
</Button>
|
||||
<Button
|
||||
onClick={bulkReject}
|
||||
className={styles.bulkAction}
|
||||
cStyle='reject'
|
||||
icon='close'>
|
||||
</Button>
|
||||
{`${selectedIds.length} comments selected`}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
<div>
|
||||
{
|
||||
nodes.map((comment, i) => {
|
||||
const status = comment.action_summaries ? 'FLAGGED' : comment.status;
|
||||
const selected = selectedIds.indexOf(comment.id) !== -1;
|
||||
return <Comment
|
||||
key={i}
|
||||
index={i}
|
||||
comment={comment}
|
||||
selected={false}
|
||||
suspectWords={suspectWords}
|
||||
bannedWords={bannedWords}
|
||||
viewUserDetail={() => {}}
|
||||
actions={actionsMap[status]}
|
||||
showBanUserDialog={showBanUserDialog}
|
||||
showSuspendUserDialog={showSuspendUserDialog}
|
||||
acceptComment={this.acceptThenReload}
|
||||
rejectComment={this.rejectThenReload}
|
||||
selected={selected}
|
||||
toggleSelect={toggleSelect}
|
||||
currentAsset={null}
|
||||
currentUserId={this.props.id}
|
||||
minimal={true} />;
|
||||
})
|
||||
}
|
||||
</div>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -178,16 +178,11 @@ span {
|
||||
margin: 0 auto;
|
||||
position: relative;
|
||||
transition: all 200ms;
|
||||
padding: 10px 0 0;
|
||||
min-height: 220px;
|
||||
padding: 10px 0;
|
||||
min-height: 0;
|
||||
|
||||
.container {
|
||||
padding: 0 14px;
|
||||
min-height: 180px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 3px 6px rgba(0,0,0,0.16), 0 3px 6px rgba(0,0,0,0.23);
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
@@ -247,6 +242,7 @@ span {
|
||||
}
|
||||
|
||||
.created {
|
||||
padding: 5px;
|
||||
color: #262626;
|
||||
font-size: 14px;
|
||||
margin-left: 15px;
|
||||
@@ -291,7 +287,6 @@ span {
|
||||
|
||||
@media (--big-viewport) {
|
||||
.listItem {
|
||||
border: 1px solid #e0e0e0;
|
||||
margin-bottom: 30px;
|
||||
|
||||
&:last-child {
|
||||
@@ -433,7 +428,6 @@ span {
|
||||
.username {
|
||||
color: blue;
|
||||
text-decoration: underline;
|
||||
padding: 5px;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
@@ -461,3 +455,15 @@ span {
|
||||
position: relative;
|
||||
}
|
||||
}
|
||||
|
||||
.minimal {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.minimalSelection {
|
||||
background-color: #ecf4ff;
|
||||
}
|
||||
|
||||
.bulkSelectInput {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,30 @@
|
||||
import React, {PropTypes} from 'react';
|
||||
import {compose, gql} from 'react-apollo';
|
||||
import {connect} from 'react-redux';
|
||||
import {bindActionCreators} from 'redux';
|
||||
import UserDetail from '../components/UserDetail';
|
||||
import withQuery from 'coral-framework/hocs/withQuery';
|
||||
import {getSlotsFragments} from 'coral-framework/helpers/plugins';
|
||||
import {getDefinitionName} from 'coral-framework/utils';
|
||||
import {
|
||||
changeUserDetailStatuses,
|
||||
clearUserDetailSelections,
|
||||
toggleSelectCommentInUserDetail
|
||||
} from 'coral-admin/src/actions/moderation';
|
||||
import {withSetCommentStatus} from 'coral-framework/graphql/mutations';
|
||||
import Comment from './Comment';
|
||||
|
||||
const commentConnectionFragment = gql`
|
||||
fragment CoralAdmin_Moderation_CommentConnection on CommentConnection {
|
||||
nodes {
|
||||
...${getDefinitionName(Comment.fragments.comment)}
|
||||
}
|
||||
hasNextPage
|
||||
startCursor
|
||||
endCursor
|
||||
}
|
||||
${Comment.fragments.comment}
|
||||
`;
|
||||
|
||||
const pluginFragments = getSlotsFragments([
|
||||
'userProfile',
|
||||
@@ -14,17 +36,42 @@ class UserDetailContainer extends React.Component {
|
||||
hideUserDetail: PropTypes.func.isRequired
|
||||
}
|
||||
|
||||
// status can be 'ACCEPTED' or 'REJECTED'
|
||||
bulkSetCommentStatus = (status) => {
|
||||
const changes = this.props.moderation.userDetailSelectedIds.map((commentId) => {
|
||||
return this.props.setCommentStatus({commentId, status});
|
||||
});
|
||||
|
||||
Promise.all(changes).then(() => {
|
||||
this.props.data.refetch(); // some comments may have moved out of this tab
|
||||
this.props.clearUserDetailSelections(); // un-select everything
|
||||
});
|
||||
}
|
||||
|
||||
bulkReject = () => {
|
||||
this.bulkSetCommentStatus('REJECTED');
|
||||
}
|
||||
|
||||
bulkAccept = () => {
|
||||
this.bulkSetCommentStatus('ACCEPTED');
|
||||
}
|
||||
|
||||
render () {
|
||||
if (!('user' in this.props.root)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <UserDetail {...this.props}/>;
|
||||
return <UserDetail
|
||||
bulkReject={this.bulkReject}
|
||||
bulkAccept={this.bulkAccept}
|
||||
changeStatus={this.props.changeUserDetailStatuses}
|
||||
toggleSelect={this.props.toggleSelectCommentInUserDetail}
|
||||
{...this.props} />;
|
||||
}
|
||||
}
|
||||
|
||||
export const withUserDetailQuery = withQuery(gql`
|
||||
query CoralAdmin_UserDetail($author_id: ID!) {
|
||||
query CoralAdmin_UserDetail($author_id: ID!, $statuses: [COMMENT_STATUS!]) {
|
||||
user(id: $author_id) {
|
||||
id
|
||||
username
|
||||
@@ -37,18 +84,40 @@ export const withUserDetailQuery = withQuery(gql`
|
||||
}
|
||||
totalComments: commentCount(query: {author_id: $author_id})
|
||||
rejectedComments: commentCount(query: {author_id: $author_id, statuses: [REJECTED]})
|
||||
comments: comments(query: {
|
||||
author_id: $author_id,
|
||||
statuses: $statuses
|
||||
}) {
|
||||
...CoralAdmin_Moderation_CommentConnection
|
||||
}
|
||||
${pluginFragments.spreads('root')}
|
||||
}
|
||||
${Comment.fragments.comment}
|
||||
${pluginFragments.definitions('user')}
|
||||
${pluginFragments.definitions('root')}
|
||||
${commentConnectionFragment}
|
||||
`, {
|
||||
options: ({id}) => {
|
||||
options: ({id, moderation: {userDetailStatuses: statuses}}) => {
|
||||
return {
|
||||
variables: {author_id: id}
|
||||
variables: {author_id: id, statuses}
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
const mapStateToProps = (state) => ({
|
||||
moderation: state.moderation.toJS()
|
||||
});
|
||||
|
||||
const mapDispatchToProps = (dispatch) => ({
|
||||
...bindActionCreators({
|
||||
changeUserDetailStatuses,
|
||||
clearUserDetailSelections,
|
||||
toggleSelectCommentInUserDetail
|
||||
}, dispatch)
|
||||
});
|
||||
|
||||
export default compose(
|
||||
connect(mapStateToProps, mapDispatchToProps),
|
||||
withUserDetailQuery,
|
||||
withSetCommentStatus,
|
||||
)(UserDetailContainer);
|
||||
|
||||
@@ -14,14 +14,18 @@ if (window.devToolsExtension) {
|
||||
middlewares.push(window.devToolsExtension());
|
||||
}
|
||||
|
||||
const coralReducers = {
|
||||
...mainReducer,
|
||||
apollo: client.reducer()
|
||||
};
|
||||
|
||||
const store = createStore(
|
||||
combineReducers({
|
||||
...mainReducer,
|
||||
apollo: client.reducer()
|
||||
}),
|
||||
combineReducers(coralReducers),
|
||||
{},
|
||||
compose(...middlewares)
|
||||
);
|
||||
|
||||
store.coralReducers = coralReducers;
|
||||
|
||||
window.coralStore = store;
|
||||
export default store;
|
||||
|
||||
@@ -2,7 +2,6 @@ import {pym} from 'coral-framework';
|
||||
import * as actions from '../constants/stream';
|
||||
|
||||
export const setActiveReplyBox = (id) => ({type: actions.SET_ACTIVE_REPLY_BOX, id});
|
||||
export const setCommentCountCache = (amount) => ({type: actions.SET_COMMENT_COUNT_CACHE, amount});
|
||||
|
||||
function removeParam(key, sourceURL) {
|
||||
let rtn = sourceURL.split('?')[0];
|
||||
@@ -38,3 +37,13 @@ export const viewAllComments = () => {
|
||||
|
||||
return {type: actions.VIEW_ALL_COMMENTS};
|
||||
};
|
||||
|
||||
export const addCommentClassName = (className) => ({
|
||||
type: actions.ADD_COMMENT_CLASSNAME,
|
||||
className
|
||||
});
|
||||
|
||||
export const removeCommentClassName = (idx) => ({
|
||||
type: actions.REMOVE_COMMENT_CLASSNAME,
|
||||
idx
|
||||
});
|
||||
|
||||
@@ -97,3 +97,17 @@
|
||||
.Wizard .textAlignRight {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.commentInfoBar {
|
||||
float: right;
|
||||
}
|
||||
|
||||
@keyframes enter {
|
||||
0% {background-color: rgba(0, 0, 0, 0);}
|
||||
50% {background-color: rgba(255,255,0, 0.2);}
|
||||
100% {background-color: rgba(0, 0, 0, 0);}
|
||||
}
|
||||
|
||||
.enter {
|
||||
animation: enter 1000ms;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import React, {PropTypes} from 'react';
|
||||
|
||||
import PermalinkButton from 'coral-plugin-permalinks/PermalinkButton';
|
||||
|
||||
import AuthorName from 'coral-plugin-author-name/AuthorName';
|
||||
|
||||
import TagLabel from 'coral-plugin-tag-label/TagLabel';
|
||||
import Content from 'coral-plugin-commentcontent/CommentContent';
|
||||
import PubDate from 'coral-plugin-pubdate/PubDate';
|
||||
import {ReplyBox, ReplyButton} from 'coral-plugin-replies';
|
||||
import FlagComment from 'coral-plugin-flags/FlagComment';
|
||||
import {TransitionGroup} from 'react-transition-group';
|
||||
import cn from 'classnames';
|
||||
|
||||
import {
|
||||
BestButton,
|
||||
IfUserCanModifyBest,
|
||||
@@ -17,15 +19,50 @@ import {
|
||||
} from 'coral-plugin-best/BestButton';
|
||||
import Slot from 'coral-framework/components/Slot';
|
||||
import LoadMore from './LoadMore';
|
||||
import IgnoredCommentTombstone from './IgnoredCommentTombstone';
|
||||
import {TopRightMenu} from './TopRightMenu';
|
||||
import classnames from 'classnames';
|
||||
import IgnoredCommentTombstone from './IgnoredCommentTombstone';
|
||||
import {EditableCommentContent} from './EditableCommentContent';
|
||||
import {getActionSummary, iPerformedThisAction} from 'coral-framework/utils';
|
||||
import {getEditableUntilDate} from './util';
|
||||
import styles from './Comment.css';
|
||||
|
||||
const isStaff = (tags) => !tags.every((t) => t.name !== 'STAFF');
|
||||
const hasTag = (tags, lookupTag) => !!tags.filter((tag) => tag.name === lookupTag).length;
|
||||
const hasComment = (nodes, id) => nodes.some((node) => node.id === id);
|
||||
|
||||
// resetCursors will return the id cursors of the first and second newest comment in
|
||||
// the current reply 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 replies = props.comment.replies;
|
||||
if (replies && replies.nodes.length) {
|
||||
const idCursors = [replies.nodes[replies.nodes.length - 1].id];
|
||||
if (replies.nodes.length >= 2) {
|
||||
idCursors.push(replies.nodes[replies.nodes.length - 2].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 replies = props.comment.replies;
|
||||
const idCursors = [];
|
||||
if (state.idCursors[alt]) {
|
||||
idCursors.push(state.idCursors[alt]);
|
||||
const index = replies.nodes.findIndex((node) => node.id === idCursors[0]);
|
||||
const prevInLine = replies.nodes[index - 1];
|
||||
if (prevInLine) {
|
||||
idCursors.push(prevInLine.id);
|
||||
}
|
||||
}
|
||||
return {idCursors};
|
||||
}
|
||||
|
||||
// hold actions links (e.g. Reply) along the comment footer
|
||||
const ActionButton = ({children}) => {
|
||||
@@ -36,7 +73,7 @@ const ActionButton = ({children}) => {
|
||||
);
|
||||
};
|
||||
|
||||
class Comment extends React.Component {
|
||||
export default class Comment extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
@@ -49,9 +86,47 @@ class Comment extends React.Component {
|
||||
// Whether the comment should be editable (e.g. after a commenter clicking the 'Edit' button on their own comment)
|
||||
isEditing: false,
|
||||
replyBoxVisible: false,
|
||||
animateEnter: false,
|
||||
...resetCursors({}, props),
|
||||
};
|
||||
}
|
||||
|
||||
componentWillReceiveProps(next) {
|
||||
const {comment: {replies: prevReplies}} = this.props;
|
||||
const {comment: {replies: nextReplies}} = next;
|
||||
if (
|
||||
prevReplies && nextReplies &&
|
||||
nextReplies.nodes.length < prevReplies.nodes.length
|
||||
) {
|
||||
|
||||
// Invalidate first cursor if referenced comment was removed.
|
||||
if (this.state.idCursors[0] && !hasComment(nextReplies.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(nextReplies.nodes, this.state.idCursors[1])) {
|
||||
this.setState(invalidateCursor(1, this.state, next));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
componentWillEnter(callback) {
|
||||
callback();
|
||||
const userId = this.props.currentUser ? this.props.currentUser.id : null;
|
||||
if (this.props.comment.id.indexOf('pending') >= 0) {
|
||||
return;
|
||||
}
|
||||
if (userId && this.props.comment.user.id === userId) {
|
||||
|
||||
// This comment was just added by currentUser.
|
||||
if (Date.now() - Number(new Date(this.props.comment.created_at)) < 30 * 1000) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
this.setState({animateEnter: true});
|
||||
}
|
||||
|
||||
static propTypes = {
|
||||
reactKey: PropTypes.string.isRequired,
|
||||
|
||||
@@ -67,6 +142,7 @@ 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,
|
||||
@@ -127,6 +203,50 @@ class Comment extends React.Component {
|
||||
}
|
||||
}
|
||||
|
||||
hasIgnoredReplies() {
|
||||
return this.props.comment.replies &&
|
||||
this.props.comment.replies.nodes.some((reply) => this.props.commentIsIgnored(reply));
|
||||
}
|
||||
|
||||
loadNewReplies = () => {
|
||||
const {replies, replyCount, id} = this.props.comment;
|
||||
if (replyCount > replies.nodes.length) {
|
||||
this.props.loadMore(id).then(() => {
|
||||
this.setState(resetCursors(this.state, this.props));
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.setState(resetCursors);
|
||||
};
|
||||
|
||||
// getVisibileReplies returns a list containing comments
|
||||
// which were authored by current user or comes before the `idCursor`.
|
||||
getVisibileReplies() {
|
||||
const {comment: {replies}, currentUser, liveUpdates} = this.props;
|
||||
const idCursor = this.state.idCursors[0];
|
||||
const userId = currentUser ? currentUser.id : null;
|
||||
|
||||
if (!replies) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (liveUpdates) {
|
||||
return replies.nodes;
|
||||
}
|
||||
|
||||
const view = [];
|
||||
let pastCursor = false;
|
||||
replies.nodes.forEach((comment) => {
|
||||
if (idCursor && !pastCursor || comment.user.id === userId) {
|
||||
view.push(comment);
|
||||
}
|
||||
if (comment.id === idCursor) {
|
||||
pastCursor = true;
|
||||
}
|
||||
});
|
||||
return view;
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this._isMounted = true;
|
||||
if (this.editWindowExpiryTimeout) {
|
||||
@@ -151,30 +271,35 @@ class Comment extends React.Component {
|
||||
}
|
||||
render () {
|
||||
const {
|
||||
comment,
|
||||
parentId,
|
||||
currentUser,
|
||||
asset,
|
||||
depth,
|
||||
postComment,
|
||||
addNotification,
|
||||
showSignInDialog,
|
||||
highlighted,
|
||||
comment,
|
||||
postFlag,
|
||||
parentId,
|
||||
ignoreUser,
|
||||
highlighted,
|
||||
postComment,
|
||||
currentUser,
|
||||
postDontAgree,
|
||||
loadMore,
|
||||
setActiveReplyBox,
|
||||
activeReplyBox,
|
||||
deleteAction,
|
||||
addCommentTag,
|
||||
removeCommentTag,
|
||||
ignoreUser,
|
||||
disableReply,
|
||||
commentIsIgnored,
|
||||
maxCharCount,
|
||||
charCountEnable
|
||||
addCommentTag,
|
||||
addNotification,
|
||||
charCountEnable,
|
||||
showSignInDialog,
|
||||
removeCommentTag,
|
||||
liveUpdates,
|
||||
commentIsIgnored,
|
||||
commentClassNames = []
|
||||
} = this.props;
|
||||
|
||||
const view = this.getVisibileReplies();
|
||||
|
||||
const hasMoreComments = comment.replies && (comment.replies.hasNextPage || comment.replies.nodes.length > view.length);
|
||||
const replyCount = this.hasIgnoredReplies() ? '' : comment.replyCount;
|
||||
const flagSummary = getActionSummary('FlagActionSummary', comment);
|
||||
const dontAgreeSummary = getActionSummary(
|
||||
'DontAgreeActionSummary',
|
||||
@@ -224,9 +349,39 @@ class Comment extends React.Component {
|
||||
() => 'Failed to remove best comment tag'
|
||||
);
|
||||
|
||||
/**
|
||||
* classNamesToAdd
|
||||
* adds classNames based on condition
|
||||
* classnames is an array of objects with key as classnames and value as conditions
|
||||
* i.e:
|
||||
* {
|
||||
* 'myClassName': { tags: ['STAFF']}
|
||||
* }
|
||||
*
|
||||
* This will add myClassName to comments tagged with STAFF TAG.
|
||||
* **/
|
||||
|
||||
const classNamesToAdd = commentClassNames.reduce((acc, className) => {
|
||||
let res = [];
|
||||
|
||||
// Adding classNames based on tags
|
||||
Object.keys(className).forEach((cn) => {
|
||||
const condition = className[cn];
|
||||
condition.tags.forEach((tag) => {
|
||||
if (hasTag(comment.tags, tag)) {
|
||||
res = [...res, cn];
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// TODO: Compare rest of the comment obj to the condition if needed
|
||||
|
||||
return [...acc, ...res];
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={commentClass}
|
||||
className={cn(commentClass, 'talk-stream-comment-wrapper', classNamesToAdd, {[styles.enter]: this.state.animateEnter})}
|
||||
id={`c_${comment.id}`}
|
||||
style={{marginLeft: depth * 30}}
|
||||
>
|
||||
@@ -234,15 +389,15 @@ class Comment extends React.Component {
|
||||
<div
|
||||
className={highlighted === comment.id ? 'highlighted-comment' : ''}
|
||||
>
|
||||
<AuthorName author={comment.user} />
|
||||
<AuthorName author={comment.user} className={'talk-stream-comment-user-name'} />
|
||||
{isStaff(comment.tags) ? <TagLabel>Staff</TagLabel> : null}
|
||||
|
||||
{commentIsBest(comment)
|
||||
? <TagLabel><BestIndicator /></TagLabel>
|
||||
: null }
|
||||
|
||||
<span className={styles.bylineSecondary}>
|
||||
<PubDate created_at={comment.created_at} />
|
||||
<span className={`${styles.bylineSecondary} talk-stream-comment-user-byline`} >
|
||||
<PubDate created_at={comment.created_at} className={'talk-stream-comment-published-date'} />
|
||||
{
|
||||
(comment.editing && comment.editing.edited)
|
||||
? <span> <span className={styles.editedMarker}>(Edited)</span></span>
|
||||
@@ -251,11 +406,13 @@ class Comment extends React.Component {
|
||||
</span>
|
||||
|
||||
<Slot
|
||||
className={styles.commentInfoBar}
|
||||
fill="commentInfoBar"
|
||||
data={this.props.data}
|
||||
root={this.props.root}
|
||||
depth={depth}
|
||||
comment={comment}
|
||||
commentId={comment.id}
|
||||
data={this.props.data}
|
||||
root={this.props.root}
|
||||
inline
|
||||
/>
|
||||
|
||||
@@ -263,17 +420,17 @@ class Comment extends React.Component {
|
||||
(comment.user.id === currentUser.id))
|
||||
|
||||
/* User can edit/delete their own comment for a short window after posting */
|
||||
? <span className={classnames(styles.topRight)}>
|
||||
? <span className={cn(styles.topRight)}>
|
||||
{
|
||||
commentIsStillEditable(comment) &&
|
||||
<a
|
||||
className={classnames(styles.link, {[styles.active]: this.state.isEditing})}
|
||||
className={cn(styles.link, {[styles.active]: this.state.isEditing})}
|
||||
onClick={this.onClickEdit}>Edit</a>
|
||||
}
|
||||
</span>
|
||||
|
||||
/* TopRightMenu allows currentUser to ignore other users' comments */
|
||||
: <span className={classnames(styles.topRight, styles.topRightMenu)}>
|
||||
: <span className={cn(styles.topRight, styles.topRightMenu)}>
|
||||
<TopRightMenu
|
||||
comment={comment}
|
||||
ignoreUser={ignoreUser}
|
||||
@@ -308,15 +465,6 @@ class Comment extends React.Component {
|
||||
commentId={comment.id}
|
||||
inline
|
||||
/>
|
||||
{!disableReply &&
|
||||
<ActionButton>
|
||||
<ReplyButton
|
||||
onClick={() => setActiveReplyBox(comment.id)}
|
||||
parentCommentId={parentId || comment.id}
|
||||
currentUserId={currentUser && currentUser.id}
|
||||
banned={false}
|
||||
/>
|
||||
</ActionButton>}
|
||||
<ActionButton>
|
||||
<IfUserCanModifyBest user={currentUser}>
|
||||
<BestButton
|
||||
@@ -326,6 +474,15 @@ class Comment extends React.Component {
|
||||
/>
|
||||
</IfUserCanModifyBest>
|
||||
</ActionButton>
|
||||
{!disableReply &&
|
||||
<ActionButton>
|
||||
<ReplyButton
|
||||
onClick={() => setActiveReplyBox(comment.id)}
|
||||
parentCommentId={parentId || comment.id}
|
||||
currentUserId={currentUser && currentUser.id}
|
||||
banned={false}
|
||||
/>
|
||||
</ActionButton>}
|
||||
<Slot
|
||||
fill="commentActions"
|
||||
data={this.props.data}
|
||||
@@ -369,53 +526,54 @@ class Comment extends React.Component {
|
||||
assetId={asset.id}
|
||||
/>
|
||||
: null}
|
||||
{comment.replies &&
|
||||
comment.replies.nodes.map((reply) => {
|
||||
return commentIsIgnored(reply)
|
||||
? <IgnoredCommentTombstone key={reply.id} />
|
||||
: <Comment
|
||||
data={this.props.data}
|
||||
root={this.props.root}
|
||||
setActiveReplyBox={setActiveReplyBox}
|
||||
disableReply={disableReply}
|
||||
activeReplyBox={activeReplyBox}
|
||||
addNotification={addNotification}
|
||||
parentId={comment.id}
|
||||
postComment={postComment}
|
||||
editComment={this.props.editComment}
|
||||
depth={depth + 1}
|
||||
asset={asset}
|
||||
highlighted={highlighted}
|
||||
currentUser={currentUser}
|
||||
postFlag={postFlag}
|
||||
deleteAction={deleteAction}
|
||||
addCommentTag={addCommentTag}
|
||||
removeCommentTag={removeCommentTag}
|
||||
ignoreUser={ignoreUser}
|
||||
charCountEnable={charCountEnable}
|
||||
maxCharCount={maxCharCount}
|
||||
showSignInDialog={showSignInDialog}
|
||||
reactKey={reply.id}
|
||||
key={reply.id}
|
||||
comment={reply}
|
||||
/>;
|
||||
})}
|
||||
{comment.replies &&
|
||||
<div className="coral-load-more-replies">
|
||||
<LoadMore
|
||||
topLevel={false}
|
||||
replyCount={comment.replyCount}
|
||||
moreComments={comment.replyCount > comment.replies.nodes.length}
|
||||
loadMore={() => loadMore(comment.id)}
|
||||
/>
|
||||
</div>}
|
||||
|
||||
<TransitionGroup>
|
||||
{view.map((reply) => {
|
||||
return commentIsIgnored(reply)
|
||||
? <IgnoredCommentTombstone key={reply.id} />
|
||||
: <Comment
|
||||
data={this.props.data}
|
||||
root={this.props.root}
|
||||
setActiveReplyBox={setActiveReplyBox}
|
||||
disableReply={disableReply}
|
||||
activeReplyBox={activeReplyBox}
|
||||
addNotification={addNotification}
|
||||
parentId={comment.id}
|
||||
postComment={postComment}
|
||||
editComment={this.props.editComment}
|
||||
depth={depth + 1}
|
||||
asset={asset}
|
||||
highlighted={highlighted}
|
||||
currentUser={currentUser}
|
||||
postFlag={postFlag}
|
||||
deleteAction={deleteAction}
|
||||
addCommentTag={addCommentTag}
|
||||
removeCommentTag={removeCommentTag}
|
||||
ignoreUser={ignoreUser}
|
||||
charCountEnable={charCountEnable}
|
||||
maxCharCount={maxCharCount}
|
||||
showSignInDialog={showSignInDialog}
|
||||
commentIsIgnored={commentIsIgnored}
|
||||
liveUpdates={liveUpdates}
|
||||
reactKey={reply.id}
|
||||
key={reply.id}
|
||||
comment={reply}
|
||||
/>;
|
||||
})}
|
||||
</TransitionGroup>
|
||||
<div className="coral-load-more-replies">
|
||||
<LoadMore
|
||||
topLevel={false}
|
||||
replyCount={replyCount}
|
||||
moreComments={hasMoreComments}
|
||||
loadMore={this.loadNewReplies}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Comment;
|
||||
|
||||
// return whether the comment is editable
|
||||
function commentIsStillEditable (comment) {
|
||||
const editing = comment && comment.editing;
|
||||
|
||||
@@ -41,10 +41,10 @@ export default class Embed extends React.Component {
|
||||
return (
|
||||
<div>
|
||||
<div className="commentStream">
|
||||
<TabBar onChange={this.changeTab} activeTab={activeTab}>
|
||||
<Tab><Count count={totalCommentCount} /></Tab>
|
||||
<Tab>{t('framework.my_profile')}</Tab>
|
||||
<Tab restricted={!can(user, 'UPDATE_CONFIG')}>{t('framework.configure_stream')}</Tab>
|
||||
<TabBar onChange={this.changeTab} activeTab={activeTab} className='talk-stream-tabbar'>
|
||||
<Tab className={'talk-stream-comment-count-tab'} id='stream'><Count count={totalCommentCount}/></Tab>
|
||||
<Tab className={'talk-stream-profile-tab'} id='profile'>{t('framework.my_profile')}</Tab>
|
||||
<Tab className={'talk-stream-configuration-tab'} id='config' restricted={!can(user, 'UPDATE_CONFIG')}>{t('framework.configure_stream')}</Tab>
|
||||
</TabBar>
|
||||
{commentId &&
|
||||
<Button
|
||||
|
||||
@@ -3,18 +3,22 @@ import React from 'react';
|
||||
import t from 'coral-framework/services/i18n';
|
||||
|
||||
// Render in place of a Comment when the author of the comment is ignored
|
||||
const IgnoredCommentTombstone = () => (
|
||||
<div>
|
||||
<hr aria-hidden={true} />
|
||||
<p style={{
|
||||
backgroundColor: '#F0F0F0',
|
||||
textAlign: 'center',
|
||||
padding: '1em',
|
||||
color: '#3E4F71',
|
||||
}}>
|
||||
{t('framework.comment_is_ignored')}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
class IgnoredCommentTombstone extends React.Component {
|
||||
render() {
|
||||
return (
|
||||
<div>
|
||||
<hr aria-hidden={true} />
|
||||
<p style={{
|
||||
backgroundColor: '#F0F0F0',
|
||||
textAlign: 'center',
|
||||
padding: '1em',
|
||||
color: '#3E4F71',
|
||||
}}>
|
||||
{t('framework.comment_is_ignored')}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default IgnoredCommentTombstone;
|
||||
|
||||
@@ -9,6 +9,9 @@ class LoadMore extends React.Component {
|
||||
}
|
||||
|
||||
replyCountFormat = (count) => {
|
||||
if (!count) {
|
||||
return t('framework.view_all_replies_unknown_number');
|
||||
}
|
||||
if (count === 1) {
|
||||
return t('framework.view_reply');
|
||||
}
|
||||
|
||||
@@ -2,22 +2,14 @@ import React, {PropTypes} from 'react';
|
||||
|
||||
import t from 'coral-framework/services/i18n';
|
||||
|
||||
const onLoadMoreClick = ({loadMore, commentCount, setCommentCountCache}) => (e) => {
|
||||
e.preventDefault();
|
||||
setCommentCountCache(commentCount);
|
||||
loadMore();
|
||||
};
|
||||
|
||||
const NewCount = (props) => {
|
||||
const newComments = props.commentCount - props.commentCountCache;
|
||||
|
||||
const NewCount = ({count, loadMore}) => {
|
||||
return <div className='coral-new-comments coral-load-more'>
|
||||
{
|
||||
props.commentCountCache && newComments > 0 ?
|
||||
<button onClick={onLoadMoreClick(props)}>
|
||||
{newComments === 1
|
||||
? t('framework.new_count', newComments, t('framework.comment'))
|
||||
: t('framework.new_count', newComments, t('framework.comments'))}
|
||||
count ?
|
||||
<button onClick={loadMore}>
|
||||
{count === 1
|
||||
? t('framework.new_count', count, t('framework.comment'))
|
||||
: t('framework.new_count', count, t('framework.comments'))}
|
||||
</button>
|
||||
: null
|
||||
}
|
||||
@@ -25,8 +17,7 @@ const NewCount = (props) => {
|
||||
};
|
||||
|
||||
NewCount.propTypes = {
|
||||
commentCount: PropTypes.number.isRequired,
|
||||
commentCountCache: PropTypes.number,
|
||||
count: PropTypes.number.isRequired,
|
||||
loadMore: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
|
||||
@@ -1,21 +1,92 @@
|
||||
import React, {PropTypes} from 'react';
|
||||
import LoadMore from './LoadMore';
|
||||
import NewCount from './NewCount';
|
||||
|
||||
import Comment from '../containers/Comment';
|
||||
import Comment from '../components/Comment';
|
||||
import SuspendedAccount from './SuspendedAccount';
|
||||
import RestrictedMessageBox
|
||||
from 'coral-framework/components/RestrictedMessageBox';
|
||||
import Slot from 'coral-framework/components/Slot';
|
||||
import InfoBox from 'coral-plugin-infobox/InfoBox';
|
||||
import {can} from 'coral-framework/services/perms';
|
||||
import {ModerationLink} from 'coral-plugin-moderation';
|
||||
import RestrictedMessageBox
|
||||
from 'coral-framework/components/RestrictedMessageBox';
|
||||
import t, {timeago} from 'coral-framework/services/i18n';
|
||||
import CommentBox from 'coral-plugin-commentbox/CommentBox';
|
||||
import QuestionBox from 'coral-plugin-questionbox/QuestionBox';
|
||||
import IgnoredCommentTombstone from './IgnoredCommentTombstone';
|
||||
import t, {timeago} from 'coral-framework/services/i18n';
|
||||
import NewCount from './NewCount';
|
||||
import {TransitionGroup} from 'react-transition-group';
|
||||
|
||||
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 Stream extends React.Component {
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = resetCursors(this.state, props);
|
||||
}
|
||||
|
||||
componentWillReceiveProps(next) {
|
||||
const {root: {asset: {comments: prevComments}}} = this.props;
|
||||
const {root: {asset: {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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
viewNewComments = () => {
|
||||
this.setState(resetCursors);
|
||||
};
|
||||
|
||||
setActiveReplyBox = (reactKey) => {
|
||||
if (!this.props.auth.user) {
|
||||
this.props.showSignInDialog();
|
||||
@@ -24,8 +95,33 @@ class Stream extends React.Component {
|
||||
}
|
||||
};
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
commentClassNames,
|
||||
root: {asset, asset: {comments}, comment, me},
|
||||
postComment,
|
||||
addNotification,
|
||||
@@ -34,13 +130,13 @@ class Stream extends React.Component {
|
||||
deleteAction,
|
||||
showSignInDialog,
|
||||
addCommentTag,
|
||||
removeCommentTag,
|
||||
pluginProps,
|
||||
ignoreUser,
|
||||
auth: {loggedIn, user},
|
||||
commentCountCache,
|
||||
removeCommentTag,
|
||||
pluginProps,
|
||||
editName
|
||||
} = this.props;
|
||||
const view = this.getVisibleComments();
|
||||
const open = asset.closedAt === null;
|
||||
|
||||
// even though the permalinked comment is the highlighted one, we're displaying its parent + replies
|
||||
@@ -97,8 +193,6 @@ class Stream extends React.Component {
|
||||
postComment={this.props.postComment}
|
||||
appendItemArray={this.props.appendItemArray}
|
||||
updateItem={this.props.updateItem}
|
||||
setCommentCountCache={this.props.setCommentCountCache}
|
||||
commentCountCache={commentCountCache}
|
||||
assetId={asset.id}
|
||||
premod={asset.settings.moderation}
|
||||
isReply={false}
|
||||
@@ -108,11 +202,17 @@ class Stream extends React.Component {
|
||||
/>}
|
||||
</div>
|
||||
: <p>{asset.settings.closedMessage}</p>}
|
||||
{loggedIn &&
|
||||
|
||||
{loggedIn && (
|
||||
<ModerationLink
|
||||
assetId={asset.id}
|
||||
isAdmin={can(user, 'MODERATE_COMMENTS')}
|
||||
/>}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="talk-stream-wrapper-box">
|
||||
<Slot fill="streamBox" />
|
||||
</div>
|
||||
|
||||
{/* the highlightedComment is isolated after the user followed a permalink */}
|
||||
{highlightedComment
|
||||
@@ -139,19 +239,19 @@ class Stream extends React.Component {
|
||||
charCountEnable={asset.settings.charCountEnable}
|
||||
maxCharCount={asset.settings.charCount}
|
||||
editComment={this.props.editComment}
|
||||
liveUpdates={true}
|
||||
/>
|
||||
: <div>
|
||||
: <div className="talk-stream-comments-container">
|
||||
<NewCount
|
||||
commentCount={asset.commentCount}
|
||||
commentCountCache={commentCountCache}
|
||||
setCommentCountCache={this.props.setCommentCountCache}
|
||||
loadMore={this.props.loadNewComments}
|
||||
count={comments.nodes.length - view.length}
|
||||
loadMore={this.viewNewComments}
|
||||
/>
|
||||
<div className="embed__stream">
|
||||
{comments && comments.nodes.map((comment) => {
|
||||
<TransitionGroup component='div' className="embed__stream">
|
||||
{view.map((comment) => {
|
||||
return commentIsIgnored(comment)
|
||||
? <IgnoredCommentTombstone key={comment.id} />
|
||||
: <Comment
|
||||
commentClassNames={commentClassNames}
|
||||
data={this.props.data}
|
||||
root={this.props.root}
|
||||
disableReply={!open}
|
||||
@@ -178,9 +278,10 @@ class Stream extends React.Component {
|
||||
charCountEnable={asset.settings.charCountEnable}
|
||||
maxCharCount={asset.settings.charCount}
|
||||
editComment={this.props.editComment}
|
||||
liveUpdates={false}
|
||||
/>;
|
||||
})}
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
<LoadMore
|
||||
topLevel={true}
|
||||
moreComments={asset.comments.hasNextPage}
|
||||
|
||||
@@ -41,7 +41,10 @@ class SuspendedAccount extends Component {
|
||||
<span>{
|
||||
canEditName ?
|
||||
t('framework.edit_name.msg')
|
||||
: t('framework.banned_account_msg')
|
||||
:
|
||||
<span>
|
||||
<b>{t('framework.banned_account_header')}</b><br/> {t('framework.banned_account_body')}
|
||||
</span>
|
||||
}</span>
|
||||
{
|
||||
canEditName ?
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export const SET_ACTIVE_REPLY_BOX = 'SET_ACTIVE_REPLY_BOX';
|
||||
export const SET_COMMENT_COUNT_CACHE = 'SET_COMMENT_COUNT_CACHE';
|
||||
export const ADDTL_COMMENTS_ON_LOAD_MORE = 10;
|
||||
export const NEW_COMMENT_COUNT_POLL_INTERVAL = 20000;
|
||||
export const VIEW_ALL_COMMENTS = 'VIEW_ALL_COMMENTS';
|
||||
export const ADD_COMMENT_CLASSNAME = 'ADD_COMMENT_CLASSNAME';
|
||||
export const REMOVE_COMMENT_CLASSNAME = 'REMOVE_COMMENT_CLASSNAME';
|
||||
|
||||
@@ -14,7 +14,7 @@ import Embed from '../components/Embed';
|
||||
import Stream from './Stream';
|
||||
|
||||
import {setActiveTab} from '../actions/embed';
|
||||
import {setCommentCountCache, viewAllComments} from '../actions/stream';
|
||||
import {viewAllComments} from '../actions/stream';
|
||||
|
||||
const {logout, checkLogin} = authActions;
|
||||
const {fetchAssetSuccess} = assetActions;
|
||||
@@ -33,18 +33,11 @@ class EmbedContainer extends React.Component {
|
||||
|
||||
// TODO: remove asset data from redux store.
|
||||
fetchAssetSuccess(nextProps.root.asset);
|
||||
|
||||
const {setCommentCountCache, commentCountCache} = this.props;
|
||||
const {asset} = nextProps.root;
|
||||
|
||||
if (commentCountCache === -1) {
|
||||
setCommentCountCache(asset.commentCount);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (!isEqual(prevProps.root.comment, this.props.root.comment)) {
|
||||
if (!prevProps.root.comment && this.props.root.comment) {
|
||||
|
||||
// Scroll to a permalinked comment if one is in the URL once the page is done rendering.
|
||||
setTimeout(() => pym.scrollParentToChildEl('coralStream'), 0);
|
||||
@@ -87,7 +80,6 @@ export const withEmbedQuery = withQuery(EMBED_QUERY, {
|
||||
|
||||
const mapStateToProps = (state) => ({
|
||||
auth: state.auth.toJS(),
|
||||
commentCountCache: state.stream.commentCountCache,
|
||||
commentId: state.stream.commentId,
|
||||
assetId: state.stream.assetId,
|
||||
assetUrl: state.stream.assetUrl,
|
||||
@@ -103,7 +95,6 @@ const mapDispatchToProps = (dispatch) =>
|
||||
setActiveTab,
|
||||
viewAllComments,
|
||||
fetchAssetSuccess,
|
||||
setCommentCountCache
|
||||
},
|
||||
dispatch
|
||||
);
|
||||
|
||||
@@ -2,34 +2,94 @@ import React from 'react';
|
||||
import {gql, compose} from 'react-apollo';
|
||||
import {connect} from 'react-redux';
|
||||
import {bindActionCreators} from 'redux';
|
||||
import {NEW_COMMENT_COUNT_POLL_INTERVAL, ADDTL_COMMENTS_ON_LOAD_MORE} from '../constants/stream';
|
||||
import {ADDTL_COMMENTS_ON_LOAD_MORE} from '../constants/stream';
|
||||
import {
|
||||
withPostComment, withPostFlag, withPostDontAgree, withDeleteAction,
|
||||
withAddCommentTag, withRemoveCommentTag, withIgnoreUser, withEditComment,
|
||||
} from 'coral-framework/graphql/mutations';
|
||||
import update from 'immutability-helper';
|
||||
|
||||
import {notificationActions, authActions} from 'coral-framework';
|
||||
import {editName} from 'coral-framework/actions/user';
|
||||
import {setCommentCountCache, setActiveReplyBox} from '../actions/stream';
|
||||
import {setActiveReplyBox} from '../actions/stream';
|
||||
import Stream from '../components/Stream';
|
||||
import Comment from './Comment';
|
||||
import {withFragments} from 'coral-framework/hocs';
|
||||
import {Spinner} from 'coral-ui';
|
||||
import {getDefinitionName} from 'coral-framework/utils';
|
||||
import {
|
||||
findCommentInEmbedQuery,
|
||||
insertCommentIntoEmbedQuery,
|
||||
removeCommentFromEmbedQuery,
|
||||
insertFetchedCommentsIntoEmbedQuery,
|
||||
} from '../graphql/utils';
|
||||
|
||||
const {showSignInDialog} = authActions;
|
||||
const {addNotification} = notificationActions;
|
||||
|
||||
class StreamContainer extends React.Component {
|
||||
getCounts = (variables) => {
|
||||
return this.props.data.fetchMore({
|
||||
query: LOAD_COMMENT_COUNTS_QUERY,
|
||||
variables,
|
||||
subscriptions = [];
|
||||
|
||||
// Apollo requires this, even though we don't use it...
|
||||
updateQuery: (data) => data,
|
||||
subscribeToUpdates() {
|
||||
const sub1 = this.props.data.subscribeToMore({
|
||||
document: COMMENTS_EDITED_SUBSCRIPTION,
|
||||
variables: {
|
||||
assetId: this.props.root.asset.id,
|
||||
},
|
||||
updateQuery: (prev, {subscriptionData: {data: {commentEdited}}}) => {
|
||||
|
||||
// Ignore mutations from me.
|
||||
// TODO: need way to detect mutations created by this client, and allow mutations from other clients.
|
||||
if (this.props.auth.user && commentEdited.user.id === this.props.auth.user.id) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
// Exit when comment is not in the query.
|
||||
if (!findCommentInEmbedQuery(prev, commentEdited.id)) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
if (['PREMOD', 'REJECTED'].includes(commentEdited.status)) {
|
||||
return removeCommentFromEmbedQuery(prev, commentEdited.id);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const sub2 = this.props.data.subscribeToMore({
|
||||
document: COMMENTS_ADDED_SUBSCRIPTION,
|
||||
variables: {
|
||||
assetId: this.props.root.asset.id,
|
||||
},
|
||||
updateQuery: (prev, {subscriptionData: {data: {commentAdded}}}) => {
|
||||
|
||||
// Ignore mutations from me.
|
||||
// TODO: need way to detect mutations created by this client, and allow mutations from other clients.
|
||||
if (this.props.auth.user && commentAdded.user.id === this.props.auth.user.id) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
// Exit if author is ignored.
|
||||
if (
|
||||
this.props.root.me &&
|
||||
this.props.root.me.ignoredUsers.some(({id}) => id === commentAdded.user.id)) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
// Exit when comment is already in the query.
|
||||
if (findCommentInEmbedQuery(prev, commentAdded.id)) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
return insertCommentIntoEmbedQuery(prev, commentAdded);
|
||||
}
|
||||
});
|
||||
|
||||
this.subscriptions.push(sub1, sub2);
|
||||
}
|
||||
|
||||
unsubscribe() {
|
||||
this.subscriptions.forEach((unsubscribe) => unsubscribe());
|
||||
this.subscriptions = [];
|
||||
}
|
||||
|
||||
loadNewReplies = (parent_id) => {
|
||||
const comment = this.props.root.comment
|
||||
@@ -47,86 +107,11 @@ class StreamContainer extends React.Component {
|
||||
excludeIgnored: this.props.data.variables.excludeIgnored,
|
||||
},
|
||||
updateQuery: (prev, {fetchMoreResult:{comments}}) => {
|
||||
if (!comments.nodes.length) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
const updateNode = (node) =>
|
||||
update(node, {
|
||||
replies: {
|
||||
endCursor: {$set: comments.endCursor},
|
||||
nodes: {$apply: (nodes) => nodes
|
||||
.concat(comments.nodes.filter(
|
||||
(comment) => !nodes.some((node) => node.id === comment.id)
|
||||
))
|
||||
.sort(ascending)
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// highlighted comment.
|
||||
if (prev.comment) {
|
||||
if (prev.comment.parent) {
|
||||
return update(prev, {
|
||||
comment: {
|
||||
parent: {$apply: (comment) => updateNode(comment)},
|
||||
}
|
||||
});
|
||||
}
|
||||
return update(prev, {
|
||||
comment: {$apply: (comment) => updateNode(comment)},
|
||||
});
|
||||
}
|
||||
|
||||
return update(prev, {
|
||||
asset: {
|
||||
comments: {
|
||||
nodes: {
|
||||
$apply: (nodes) => nodes.map(
|
||||
(node) => node.id !== parent_id
|
||||
? node
|
||||
: updateNode(node)
|
||||
)
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
return insertFetchedCommentsIntoEmbedQuery(prev, comments, parent_id);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
loadNewComments = () => {
|
||||
return this.props.data.fetchMore({
|
||||
query: LOAD_MORE_QUERY,
|
||||
variables: {
|
||||
limit: ADDTL_COMMENTS_ON_LOAD_MORE,
|
||||
cursor: this.props.root.asset.comments.startCursor,
|
||||
parent_id: null,
|
||||
asset_id: this.props.root.asset.id,
|
||||
sort: 'CHRONOLOGICAL',
|
||||
excludeIgnored: this.props.data.variables.excludeIgnored,
|
||||
},
|
||||
updateQuery: (prev, {fetchMoreResult:{comments}}) => {
|
||||
if (!comments.nodes.length) {
|
||||
return prev;
|
||||
}
|
||||
return update(prev, {
|
||||
asset: {
|
||||
comments: {
|
||||
startCursor: {$set: comments.endCursor},
|
||||
nodes: {$apply: (nodes) => comments.nodes.filter(
|
||||
(comment) => !nodes.some((node) => node.id === comment.id)
|
||||
)
|
||||
.concat(nodes)
|
||||
.sort(descending)
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
loadMoreComments = () => {
|
||||
return this.props.data.fetchMore({
|
||||
query: LOAD_MORE_QUERY,
|
||||
@@ -139,78 +124,77 @@ class StreamContainer extends React.Component {
|
||||
excludeIgnored: this.props.data.variables.excludeIgnored,
|
||||
},
|
||||
updateQuery: (prev, {fetchMoreResult:{comments}}) => {
|
||||
if (!comments.nodes.length) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
return update(prev, {
|
||||
asset: {
|
||||
comments: {
|
||||
hasNextPage: {$set: comments.hasNextPage},
|
||||
endCursor: {$set: comments.endCursor},
|
||||
nodes: {$push: comments.nodes},
|
||||
},
|
||||
},
|
||||
});
|
||||
return insertFetchedCommentsIntoEmbedQuery(prev, comments);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
if (this.props.previousTab) {
|
||||
this.props.data.refetch()
|
||||
.then(({data: {asset: {commentCount}}}) => {
|
||||
return this.props.setCommentCountCache(commentCount);
|
||||
});
|
||||
this.props.data.refetch();
|
||||
}
|
||||
this.countPoll = setInterval(() => {
|
||||
this.getCounts(this.props.data.variables);
|
||||
}, NEW_COMMENT_COUNT_POLL_INTERVAL);
|
||||
this.subscribeToUpdates();
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.unsubscribe();
|
||||
clearInterval(this.countPoll);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.props.refetching) {
|
||||
return <Spinner />;
|
||||
}
|
||||
return <Stream
|
||||
{...this.props}
|
||||
loadMore={this.loadMore}
|
||||
loadMoreComments={this.loadMoreComments}
|
||||
loadNewComments={this.loadNewComments}
|
||||
loadNewReplies={this.loadNewReplies}
|
||||
/>;
|
||||
}
|
||||
}
|
||||
|
||||
const ascending = (a, b) => {
|
||||
const dateA = new Date(a.created_at);
|
||||
const dateB = new Date(b.created_at);
|
||||
if (dateA < dateB) { return -1; }
|
||||
if (dateA > dateB) { return 1; }
|
||||
return 0;
|
||||
};
|
||||
const commentFragment = gql`
|
||||
fragment CoralEmbedStream_Stream_comment on Comment {
|
||||
id
|
||||
...${getDefinitionName(Comment.fragments.comment)}
|
||||
replyCount(excludeIgnored: $excludeIgnored)
|
||||
replies(excludeIgnored: $excludeIgnored) {
|
||||
nodes {
|
||||
id
|
||||
...${getDefinitionName(Comment.fragments.comment)}
|
||||
}
|
||||
hasNextPage
|
||||
startCursor
|
||||
endCursor
|
||||
}
|
||||
}
|
||||
${Comment.fragments.comment}
|
||||
`;
|
||||
|
||||
const descending = (a, b) => ascending(a, b) * -1;
|
||||
|
||||
const LOAD_COMMENT_COUNTS_QUERY = gql`
|
||||
query CoralEmbedStream_LoadCommentCounts($assetUrl: String, , $commentId: ID!, $assetId: ID, $hasComment: Boolean!, $excludeIgnored: Boolean) {
|
||||
comment(id: $commentId) @include(if: $hasComment) {
|
||||
id
|
||||
const COMMENTS_ADDED_SUBSCRIPTION = gql`
|
||||
subscription onCommentAdded($assetId: ID!, $excludeIgnored: Boolean){
|
||||
commentAdded(asset_id: $assetId){
|
||||
parent {
|
||||
id
|
||||
replyCount(excludeIgnored: $excludeIgnored)
|
||||
}
|
||||
replyCount(excludeIgnored: $excludeIgnored)
|
||||
...CoralEmbedStream_Stream_comment
|
||||
}
|
||||
asset(id: $assetId, url: $assetUrl) {
|
||||
}
|
||||
${commentFragment}
|
||||
`;
|
||||
|
||||
const COMMENTS_EDITED_SUBSCRIPTION = gql`
|
||||
subscription onCommentEdited($assetId: ID!){
|
||||
commentEdited(asset_id: $assetId){
|
||||
id
|
||||
commentCount(excludeIgnored: $excludeIgnored)
|
||||
comments(limit: 10) @skip(if: $hasComment) {
|
||||
nodes {
|
||||
id
|
||||
replyCount(excludeIgnored: $excludeIgnored)
|
||||
}
|
||||
body
|
||||
status
|
||||
editing {
|
||||
edited
|
||||
}
|
||||
user {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -241,24 +225,6 @@ const LOAD_MORE_QUERY = gql`
|
||||
${Comment.fragments.comment}
|
||||
`;
|
||||
|
||||
const commentFragment = gql`
|
||||
fragment CoralEmbedStream_Stream_comment on Comment {
|
||||
id
|
||||
...${getDefinitionName(Comment.fragments.comment)}
|
||||
replyCount(excludeIgnored: $excludeIgnored)
|
||||
replies {
|
||||
nodes {
|
||||
id
|
||||
...${getDefinitionName(Comment.fragments.comment)}
|
||||
}
|
||||
hasNextPage
|
||||
startCursor
|
||||
endCursor
|
||||
}
|
||||
}
|
||||
${Comment.fragments.comment}
|
||||
`;
|
||||
|
||||
const fragments = {
|
||||
root: gql`
|
||||
fragment CoralEmbedStream_Stream_root on RootQuery {
|
||||
@@ -316,6 +282,7 @@ const fragments = {
|
||||
|
||||
const mapStateToProps = (state) => ({
|
||||
auth: state.auth.toJS(),
|
||||
refetching: state.embed.refetching,
|
||||
commentCountCache: state.stream.commentCountCache,
|
||||
activeReplyBox: state.stream.activeReplyBox,
|
||||
commentId: state.stream.commentId,
|
||||
@@ -323,6 +290,7 @@ const mapStateToProps = (state) => ({
|
||||
assetUrl: state.stream.assetUrl,
|
||||
activeTab: state.embed.activeTab,
|
||||
previousTab: state.embed.previousTab,
|
||||
commentClassNames: state.stream.commentClassNames
|
||||
});
|
||||
|
||||
const mapDispatchToProps = (dispatch) =>
|
||||
@@ -331,7 +299,6 @@ const mapDispatchToProps = (dispatch) =>
|
||||
addNotification,
|
||||
setActiveReplyBox,
|
||||
editName,
|
||||
setCommentCountCache,
|
||||
}, dispatch);
|
||||
|
||||
export default compose(
|
||||
|
||||
@@ -91,6 +91,9 @@ const extension = {
|
||||
edited
|
||||
editableUntil
|
||||
}
|
||||
parent {
|
||||
id
|
||||
}
|
||||
}
|
||||
`,
|
||||
},
|
||||
@@ -141,9 +144,12 @@ const extension = {
|
||||
parent_id,
|
||||
asset_id,
|
||||
action_summaries: [],
|
||||
tags,
|
||||
tags: tags.map((t) => ({name: t, __typename: 'Tag'})),
|
||||
status: null,
|
||||
replyCount: 0,
|
||||
parent: parent_id
|
||||
? {id: parent_id}
|
||||
: null,
|
||||
replies: {
|
||||
__typename: 'CommentConnection',
|
||||
nodes: [],
|
||||
@@ -165,7 +171,7 @@ const extension = {
|
||||
if (prev.asset.settings.moderation === 'PRE' || comment.status === 'PREMOD' || comment.status === 'REJECTED') {
|
||||
return prev;
|
||||
}
|
||||
return insertCommentIntoEmbedQuery(prev, parent_id, comment);
|
||||
return insertCommentIntoEmbedQuery(prev, comment);
|
||||
},
|
||||
}
|
||||
}),
|
||||
|
||||
@@ -1,11 +1,33 @@
|
||||
import update from 'immutability-helper';
|
||||
|
||||
function findAndInsertComment(parent, id, comment) {
|
||||
const [connectionField, countField, action] = parent.comments
|
||||
function applyToCommentsOrigin(root, callback) {
|
||||
if (root.comment) {
|
||||
if (root.comment.parent) {
|
||||
return update(root, {
|
||||
comment: {
|
||||
parent: {
|
||||
$apply: (node) => callback(node),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
return update(root, {
|
||||
comment: {
|
||||
$apply: (node) => callback(node),
|
||||
},
|
||||
});
|
||||
}
|
||||
return update(root, {
|
||||
asset: {$apply: (asset) => callback(asset)},
|
||||
});
|
||||
}
|
||||
|
||||
function findAndInsertComment(parent, comment) {
|
||||
const [connectionField, countField, action] = parent.__typename === 'Asset'
|
||||
? ['comments', 'commentCount', '$unshift']
|
||||
: ['replies', 'replyCount', '$push'];
|
||||
|
||||
if (!id || parent.id === id) {
|
||||
if (!comment.parent || parent.id === comment.parent.id) {
|
||||
return update(parent, {
|
||||
[connectionField]: {
|
||||
nodes: {[action]: [comment]},
|
||||
@@ -21,13 +43,13 @@ function findAndInsertComment(parent, id, comment) {
|
||||
[connectionField]: {
|
||||
nodes: {
|
||||
$apply: (nodes) =>
|
||||
nodes.map((node) => findAndInsertComment(node, id, comment))
|
||||
nodes.map((node) => findAndInsertComment(node, comment))
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function insertCommentIntoEmbedQuery(root, id, comment) {
|
||||
export function insertCommentIntoEmbedQuery(root, comment) {
|
||||
|
||||
// Increase total comment count by one.
|
||||
root = update(root, {
|
||||
@@ -35,30 +57,11 @@ export function insertCommentIntoEmbedQuery(root, id, comment) {
|
||||
totalCommentCount: {$apply: (c) => c + 1},
|
||||
},
|
||||
});
|
||||
|
||||
if (root.comment) {
|
||||
if (root.comment.parent) {
|
||||
return update(root, {
|
||||
comment: {
|
||||
parent: {
|
||||
$apply: (node) => findAndInsertComment(node, id, comment),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
return update(root, {
|
||||
comment: {
|
||||
$apply: (node) => findAndInsertComment(node, id, comment),
|
||||
},
|
||||
});
|
||||
}
|
||||
return update(root, {
|
||||
asset: {$apply: (asset) => findAndInsertComment(asset, id, comment)},
|
||||
});
|
||||
return applyToCommentsOrigin(root, (origin) => findAndInsertComment(origin, comment));
|
||||
}
|
||||
|
||||
function findAndRemoveComment(parent, id) {
|
||||
const [connectionField, countField] = parent.comments
|
||||
const [connectionField, countField] = parent.__typename === 'Asset'
|
||||
? ['comments', 'commentCount']
|
||||
: ['replies', 'replyCount'];
|
||||
|
||||
@@ -78,7 +81,7 @@ function findAndRemoveComment(parent, id) {
|
||||
};
|
||||
|
||||
if (parent[countField] && next.length !== connection.nodes.length) {
|
||||
changes[countField] = {$set: changes[countField] - 1};
|
||||
changes[countField] = {$set: parent[countField] - 1};
|
||||
}
|
||||
return update(parent, changes);
|
||||
}
|
||||
@@ -91,24 +94,88 @@ export function removeCommentFromEmbedQuery(root, id) {
|
||||
totalCommentCount: {$apply: (c) => c - 1},
|
||||
},
|
||||
});
|
||||
return applyToCommentsOrigin(root, (origin) => findAndRemoveComment(origin, id));
|
||||
}
|
||||
|
||||
if (root.comment) {
|
||||
if (root.comment.parent) {
|
||||
return update(root, {
|
||||
comment: {
|
||||
parent: {
|
||||
$apply: (node) => findAndRemoveComment(node, id),
|
||||
},
|
||||
},
|
||||
});
|
||||
function findComment(nodes, callback) {
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
const node = nodes[i];
|
||||
if (callback(node)) {
|
||||
return node;
|
||||
}
|
||||
return update(root, {
|
||||
comment: {
|
||||
$apply: (node) => findAndRemoveComment(node, id),
|
||||
if (node.replies) {
|
||||
const find = findComment(node.replies.nodes, callback);
|
||||
if (find){
|
||||
return find;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function findCommentInEmbedQuery(root, callbackOrId) {
|
||||
let callback = callbackOrId;
|
||||
if (typeof callbackOrId === 'string') {
|
||||
callback = (node) => node.id === callbackOrId;
|
||||
}
|
||||
if (root.comment) {
|
||||
if (callback(root.comment)) {
|
||||
return root.comment;
|
||||
}
|
||||
if (root.comment.parent && callback(root.comment.parent)) {
|
||||
return root.comment.parent;
|
||||
}
|
||||
}
|
||||
if (!root.asset.comments) {
|
||||
return false;
|
||||
}
|
||||
return findComment(root.asset.comments.nodes, callback);
|
||||
}
|
||||
|
||||
const ascending = (a, b) => {
|
||||
const dateA = new Date(a.created_at);
|
||||
const dateB = new Date(b.created_at);
|
||||
if (dateA < dateB) { return -1; }
|
||||
if (dateA > dateB) { return 1; }
|
||||
return 0;
|
||||
};
|
||||
|
||||
function findAndInsertFetchedComments(parent, comments, parent_id) {
|
||||
const isAsset = parent.__typename === 'Asset';
|
||||
const connectionField = isAsset ? 'comments' : 'replies';
|
||||
if (!parent_id && connectionField === 'comments' || parent.id === parent_id) {
|
||||
return update(parent, {
|
||||
[connectionField]: {
|
||||
hasNextPage: {$set: comments.hasNextPage},
|
||||
endCursor: {$set: comments.endCursor},
|
||||
nodes: {$apply: (nodes) => {
|
||||
if (isAsset) {
|
||||
return nodes.concat(comments.nodes);
|
||||
}
|
||||
return nodes
|
||||
.concat(comments.nodes.filter(
|
||||
(comment) => !nodes.some((node) => node.id === comment.id)
|
||||
))
|
||||
.sort(ascending);
|
||||
}},
|
||||
},
|
||||
});
|
||||
}
|
||||
return update(root, {
|
||||
asset: {$apply: (asset) => findAndRemoveComment(asset, id)},
|
||||
|
||||
const connection = parent[connectionField];
|
||||
if (!connection) {
|
||||
return parent;
|
||||
}
|
||||
return update(parent, {
|
||||
[connectionField]: {
|
||||
nodes: {
|
||||
$apply: (nodes) =>
|
||||
nodes.map((node) => findAndInsertFetchedComments(node, comments, parent_id))
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function insertFetchedCommentsIntoEmbedQuery(root, comments, parent_id) {
|
||||
return applyToCommentsOrigin(root, (origin) => findAndInsertFetchedComments(origin, comments, parent_id));
|
||||
}
|
||||
|
||||
@@ -2,30 +2,30 @@ import React from 'react';
|
||||
import {render} from 'react-dom';
|
||||
import {ApolloProvider} from 'react-apollo';
|
||||
|
||||
import {client} from 'coral-framework/services/client';
|
||||
import {checkLogin} from 'coral-framework/actions/auth';
|
||||
import './graphql';
|
||||
import {addExternalConfig} from 'coral-embed-stream/src/actions/config';
|
||||
|
||||
import reducers from './reducers';
|
||||
import {getStore, injectReducers} from 'coral-framework/services/store';
|
||||
import {getClient} from 'coral-framework/services/client';
|
||||
import AppRouter from './AppRouter';
|
||||
import {pym} from 'coral-framework';
|
||||
import {loadPluginsTranslations} from 'coral-framework/helpers/plugins';
|
||||
import {loadPluginsTranslations, injectPluginsReducers} from 'coral-framework/helpers/plugins';
|
||||
import reducers from './reducers';
|
||||
|
||||
const store = getStore();
|
||||
const client = getClient();
|
||||
|
||||
loadPluginsTranslations();
|
||||
injectPluginsReducers();
|
||||
injectReducers(reducers);
|
||||
|
||||
// Don't run this in the popup.
|
||||
if (!window.opener) {
|
||||
store.dispatch(checkLogin());
|
||||
|
||||
pym.sendMessage('getConfig');
|
||||
|
||||
pym.onMessage('config', (config) => {
|
||||
store.dispatch(addExternalConfig(JSON.parse(config)));
|
||||
store.dispatch(checkLogin());
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@ import * as actions from '../constants/embed';
|
||||
const initialState = {
|
||||
activeTab: 'stream',
|
||||
previousTab: '',
|
||||
refetching: false,
|
||||
refetchRequestId: 0,
|
||||
};
|
||||
|
||||
export default function stream(state = initialState, action) {
|
||||
@@ -13,6 +15,23 @@ export default function stream(state = initialState, action) {
|
||||
activeTab: action.tab,
|
||||
previousTab: state.activeTab,
|
||||
};
|
||||
case 'APOLLO_QUERY_INIT':
|
||||
if (action.queryString.indexOf('query CoralEmbedStream_Embed(') >= 0) {
|
||||
return {
|
||||
...state,
|
||||
refetching: action.isRefetch ? true : state.refetching,
|
||||
refetchRequestId: action.isRefetch ? action.requestId : state.refetchRequestId,
|
||||
};
|
||||
}
|
||||
return state;
|
||||
case 'APOLLO_QUERY_RESULT':
|
||||
if (action.operationName === 'CoralEmbedStream_Embed') {
|
||||
return {
|
||||
...state,
|
||||
refetching: action.requestId === state.refetchRequestId ? false : state.refetching,
|
||||
};
|
||||
}
|
||||
return state;
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,6 @@ import stream from './stream';
|
||||
|
||||
export default {
|
||||
embed,
|
||||
config,
|
||||
stream,
|
||||
config
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as actions from '../constants/stream';
|
||||
import * as authActions from 'coral-framework/constants/auth';
|
||||
|
||||
function getQueryVariable(variable) {
|
||||
let query = window.location.search.substring(1);
|
||||
@@ -16,29 +17,42 @@ function getQueryVariable(variable) {
|
||||
|
||||
const initialState = {
|
||||
activeReplyBox: '',
|
||||
commentCountCache: -1,
|
||||
assetId: getQueryVariable('asset_id'),
|
||||
assetUrl: getQueryVariable('asset_url'),
|
||||
commentId: getQueryVariable('comment_id'),
|
||||
commentClassNames: []
|
||||
};
|
||||
|
||||
export default function stream(state = initialState, action) {
|
||||
switch (action.type) {
|
||||
case authActions.LOGOUT:
|
||||
return {
|
||||
...state,
|
||||
activeReplyBox: '',
|
||||
};
|
||||
case actions.SET_ACTIVE_REPLY_BOX:
|
||||
return {
|
||||
...state,
|
||||
activeReplyBox: action.id,
|
||||
};
|
||||
case actions.SET_COMMENT_COUNT_CACHE:
|
||||
return {
|
||||
...state,
|
||||
commentCountCache: action.amount,
|
||||
};
|
||||
case actions.VIEW_ALL_COMMENTS:
|
||||
return {
|
||||
...state,
|
||||
commentId: '',
|
||||
};
|
||||
case actions.ADD_COMMENT_CLASSNAME :
|
||||
return {
|
||||
...state,
|
||||
commentClassNames: [...state.commentClassNames, action.className]
|
||||
};
|
||||
case actions.REMOVE_COMMENT_CLASSNAME :
|
||||
return {
|
||||
...state,
|
||||
commentClassNames: [
|
||||
...state.commentClassNames.slice(0, action.idx),
|
||||
...state.commentClassNames.slice(action.idx + 1)
|
||||
]
|
||||
};
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
|
||||
@@ -119,7 +119,7 @@ hr {
|
||||
.coral-plugin-questionbox-info {
|
||||
top: 0;
|
||||
border: 0;
|
||||
background: rgb(213,213,213);
|
||||
background: #F0F0F0;
|
||||
color: black;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
@@ -134,26 +134,25 @@ hr {
|
||||
|
||||
.coral-plugin-questionbox-icon.bubble{
|
||||
position: absolute;
|
||||
top: 11px;
|
||||
top: 8px;
|
||||
left: 10px;
|
||||
color: #949393;
|
||||
font-size: 20px;
|
||||
color: #9E9E9E;
|
||||
font-size: 24px;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.coral-plugin-questionbox-icon.person{
|
||||
z-index: 2;
|
||||
top: 20px;
|
||||
left: 15px;
|
||||
top: 12px;
|
||||
left: 12px;
|
||||
position: absolute;
|
||||
font-size: 24px;
|
||||
color: white;
|
||||
font-size: 33px;
|
||||
color: #262626;
|
||||
}
|
||||
|
||||
.coral-plugin-questionbox-box {
|
||||
position: relative;
|
||||
border: 0;
|
||||
background: black;
|
||||
color: white;
|
||||
padding: 20px;
|
||||
margin-left: 0px !important;
|
||||
@@ -178,6 +177,15 @@ hr {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.talk-stream-wrapper-box {
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.talk-stream-comments-container {
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.commentStream {
|
||||
/* prevent absolutely positioned final permalink popover from being clipped */
|
||||
padding-bottom: 50px;
|
||||
|
||||
@@ -147,6 +147,7 @@ function configurePymParent(pymParent, opts) {
|
||||
* @param {String} [opts.title] - Title of Stream (rendered in iframe)
|
||||
* @param {String} [opts.asset_url] - Asset URL
|
||||
* @param {String} [opts.asset_id] - Asset ID
|
||||
* @param {String} [opts.auth_token] - (optional) A jwt representing the session
|
||||
*/
|
||||
Talk.render = function(el, opts) {
|
||||
if (!el) {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
.message {
|
||||
background: #D8D8D8;
|
||||
padding: 25px;
|
||||
margin-bottom: 8px;
|
||||
background-color: white;
|
||||
border: 1px #F0F0F0 solid;
|
||||
text-align: center;
|
||||
font-weight: 100;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,9 +4,9 @@ import styles from './Slot.css';
|
||||
import {connect} from 'react-redux';
|
||||
import {getSlotElements} from 'coral-framework/helpers/plugins';
|
||||
|
||||
function Slot ({fill, inline = false, plugin_config: config, ...rest}) {
|
||||
function Slot ({fill, inline = false, className, plugin_config: config, ...rest}) {
|
||||
return (
|
||||
<div className={cn({[styles.inline]: inline, [styles.debug]: config.debug})}>
|
||||
<div className={cn({[styles.inline]: inline, [styles.debug]: config.debug}, className)}>
|
||||
{getSlotElements(fill, {...rest, config})}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export {default as Slot} from './Slot';
|
||||
@@ -0,0 +1,16 @@
|
||||
const regExp = /[-\s]+(.)?/g;
|
||||
|
||||
/**
|
||||
* Convert dash separated strings to camel case.
|
||||
*
|
||||
* @param {String} str
|
||||
* @return {String}
|
||||
*/
|
||||
|
||||
export default function camelize(str) {
|
||||
return str.replace(regExp, toUpper);
|
||||
}
|
||||
|
||||
function toUpper(match, c) {
|
||||
return c ? c.toUpperCase() : '';
|
||||
}
|
||||
@@ -1,18 +1,14 @@
|
||||
import React from 'react';
|
||||
import merge from 'lodash/merge';
|
||||
import flatten from 'lodash/flatten';
|
||||
import flattenDeep from 'lodash/flattenDeep';
|
||||
import uniq from 'lodash/uniq';
|
||||
import pick from 'lodash/pick';
|
||||
import merge from 'lodash/merge';
|
||||
import plugins from 'pluginsConfig';
|
||||
import flatten from 'lodash/flatten';
|
||||
import flattenDeep from 'lodash/flattenDeep';
|
||||
import {getDefinitionName, mergeDocuments} from 'coral-framework/utils';
|
||||
import {loadTranslations} from 'coral-framework/services/i18n';
|
||||
|
||||
export const pluginReducers = merge(
|
||||
...plugins
|
||||
.filter((o) => o.module.reducer)
|
||||
.map((o) => ({...o.module.reducer}))
|
||||
);
|
||||
import {injectReducers} from 'coral-framework/services/store';
|
||||
import camelize from './camelize';
|
||||
|
||||
/**
|
||||
* Returns React Elements for given slot.
|
||||
@@ -98,3 +94,12 @@ function getTranslations() {
|
||||
export function loadPluginsTranslations() {
|
||||
getTranslations().forEach((t) => loadTranslations(t));
|
||||
}
|
||||
|
||||
export function injectPluginsReducers() {
|
||||
const reducers = merge(
|
||||
...plugins
|
||||
.filter((o) => o.module.reducer)
|
||||
.map((o) => ({[camelize(o.plugin)] : o.module.reducer}))
|
||||
);
|
||||
injectReducers(reducers);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,30 @@
|
||||
import bowser from 'bowser';
|
||||
import * as Storage from './storage';
|
||||
import merge from 'lodash/merge';
|
||||
import {getStore} from 'coral-framework/services/store';
|
||||
|
||||
/**
|
||||
* getAuthToken returns the active auth token or null
|
||||
* Note: this method does not have access to the cookie based token used by
|
||||
* browsers that don't allow us to use cross domain iframe local storage.
|
||||
* @return {string|null}
|
||||
*/
|
||||
export const getAuthToken = () => {
|
||||
let state = getStore().getState();
|
||||
|
||||
if (state.config.auth_token) {
|
||||
|
||||
// if an auth_token exists in config, use it.
|
||||
return state.config.auth_token;
|
||||
|
||||
} else if (!bowser.safari && !bowser.ios) {
|
||||
|
||||
// Use local storage auth tokens where there's a stable api.
|
||||
return Storage.getItem('token');
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const buildOptions = (inputOptions = {}) => {
|
||||
const defaultOptions = {
|
||||
@@ -14,12 +38,10 @@ const buildOptions = (inputOptions = {}) => {
|
||||
|
||||
let options = merge({}, defaultOptions, inputOptions);
|
||||
|
||||
if (!bowser.safari && !bowser.ios) {
|
||||
let authorization = Storage.getItem('token');
|
||||
|
||||
if (authorization) {
|
||||
options.headers.Authorization = `Bearer ${authorization}`;
|
||||
}
|
||||
// Apply authToken header
|
||||
let authToken = getAuthToken();
|
||||
if (authToken !== null) {
|
||||
options.headers.Authorization = `Bearer ${authToken}`;
|
||||
}
|
||||
|
||||
if (options.method.toLowerCase() !== 'get') {
|
||||
|
||||
@@ -2,12 +2,10 @@ import auth from './auth';
|
||||
import user from './user';
|
||||
import asset from './asset';
|
||||
import {reducer as commentBox} from '../../coral-plugin-commentbox';
|
||||
import {pluginReducers} from '../helpers/plugins';
|
||||
|
||||
export default {
|
||||
auth,
|
||||
user,
|
||||
asset,
|
||||
commentBox,
|
||||
...pluginReducers
|
||||
};
|
||||
|
||||
@@ -1,28 +1,36 @@
|
||||
import ApolloClient, {addTypename} from 'apollo-client';
|
||||
import {networkInterface} from './transport';
|
||||
import {SubscriptionClient, addGraphQLSubscriptions} from 'subscriptions-transport-ws';
|
||||
|
||||
// import {SubscriptionClient, addGraphQLSubscriptions} from 'subscriptions-transport-ws';
|
||||
let client;
|
||||
|
||||
// TODO: replace absolute reference with something loaded from the store/page.
|
||||
// const wsClient = new SubscriptionClient('ws://localhost:3000/api/v1/live', {
|
||||
// reconnect: true
|
||||
// });
|
||||
// const networkInterface = addGraphQLSubscriptions(
|
||||
// getNetworkInterface(),
|
||||
// wsClient,
|
||||
// );
|
||||
export const client = new ApolloClient({
|
||||
connectToDevTools: true,
|
||||
addTypename: true,
|
||||
queryTransformer: addTypename,
|
||||
dataIdFromObject: (result) => {
|
||||
if (result.id && result.__typename) { // eslint-disable-line no-underscore-dangle
|
||||
return `${result.__typename}_${result.id}`; // eslint-disable-line no-underscore-dangle
|
||||
}
|
||||
return null;
|
||||
},
|
||||
networkInterface
|
||||
});
|
||||
export function getClient() {
|
||||
if (client) {
|
||||
return client;
|
||||
}
|
||||
|
||||
export default client;
|
||||
const protocol = location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
const wsClient = new SubscriptionClient(`${protocol}://${location.host}/api/v1/live`, {
|
||||
reconnect: true
|
||||
});
|
||||
|
||||
const networkInterfaceWithSubscriptions = addGraphQLSubscriptions(
|
||||
networkInterface,
|
||||
wsClient,
|
||||
);
|
||||
|
||||
client = new ApolloClient({
|
||||
connectToDevTools: true,
|
||||
addTypename: true,
|
||||
queryTransformer: addTypename,
|
||||
dataIdFromObject: (result) => {
|
||||
if (result.id && result.__typename) { // eslint-disable-line no-underscore-dangle
|
||||
return `${result.__typename}_${result.id}`; // eslint-disable-line no-underscore-dangle
|
||||
}
|
||||
return null;
|
||||
},
|
||||
networkInterface: networkInterfaceWithSubscriptions,
|
||||
});
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import esTA from '../../../node_modules/timeago.js/locales/es';
|
||||
import en from '../../../locales/en.yml';
|
||||
import es from '../../../locales/es.yml';
|
||||
|
||||
// Translations are happening at https://www.transifex.com/the-coral-project/talk-1/dashboard/.
|
||||
// Translations are happening at https://translate.lingohub.com/the-coral-project/dashboard
|
||||
|
||||
const defaultLanguage = 'en';
|
||||
const translations = {...en, ...es};
|
||||
|
||||
@@ -1,28 +1,7 @@
|
||||
import {createStore, combineReducers, applyMiddleware, compose} from 'redux';
|
||||
import thunk from 'redux-thunk';
|
||||
import mainReducer from '../reducers';
|
||||
import {client} from './client';
|
||||
|
||||
const apolloErrorReporter = () => (next) => (action) => {
|
||||
if (action.type === 'APOLLO_QUERY_ERROR') {
|
||||
console.error(action.error);
|
||||
}
|
||||
return next(action);
|
||||
};
|
||||
|
||||
const middlewares = [
|
||||
applyMiddleware(
|
||||
client.middleware(),
|
||||
thunk,
|
||||
apolloErrorReporter,
|
||||
),
|
||||
];
|
||||
|
||||
if (window.devToolsExtension) {
|
||||
|
||||
// we can't have the last argument of compose() be undefined
|
||||
middlewares.push(window.devToolsExtension());
|
||||
}
|
||||
import {getClient} from './client';
|
||||
|
||||
export function injectReducers(reducers) {
|
||||
const store = getStore();
|
||||
@@ -35,9 +14,30 @@ export function getStore() {
|
||||
return window.coralStore;
|
||||
}
|
||||
|
||||
const apolloErrorReporter = () => (next) => (action) => {
|
||||
if (action.type === 'APOLLO_QUERY_ERROR') {
|
||||
console.error(action.error);
|
||||
}
|
||||
return next(action);
|
||||
};
|
||||
|
||||
const middlewares = [
|
||||
applyMiddleware(
|
||||
getClient().middleware(),
|
||||
thunk,
|
||||
apolloErrorReporter,
|
||||
),
|
||||
];
|
||||
|
||||
if (window.devToolsExtension) {
|
||||
|
||||
// we can't have the last argument of compose() be undefined
|
||||
middlewares.push(window.devToolsExtension());
|
||||
}
|
||||
|
||||
const coralReducers = {
|
||||
...mainReducer,
|
||||
apollo: client.reducer()
|
||||
apollo: getClient().reducer()
|
||||
};
|
||||
|
||||
window.coralStore = createStore(
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import {print} from 'graphql-tag/printer';
|
||||
|
||||
// quick way to add the subscribe and unsubscribe functions to the network interface
|
||||
const addGraphQLSubscriptions = (networkInterface, wsClient) => {
|
||||
return Object.assign(networkInterface, {
|
||||
subscribe: (request, handler) => wsClient.subscribe({
|
||||
query: print(request.query),
|
||||
variables: request.variables,
|
||||
}, handler),
|
||||
unsubscribe: (id) => {
|
||||
wsClient.unsubscribe(id);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export default addGraphQLSubscriptions;
|
||||
@@ -1,6 +1,5 @@
|
||||
import {createNetworkInterface} from 'apollo-client';
|
||||
import * as Storage from '../helpers/storage';
|
||||
import bowser from 'bowser';
|
||||
import {getAuthToken} from '../helpers/request';
|
||||
|
||||
//==============================================================================
|
||||
// NETWORK INTERFACE
|
||||
@@ -23,8 +22,9 @@ networkInterface.use([{
|
||||
req.options.headers = {}; // Create the header object if needed.
|
||||
}
|
||||
|
||||
if (!bowser.safari && !bowser.ios) {
|
||||
req.options.headers['authorization'] = `Bearer ${Storage.getItem('token')}`;
|
||||
let authToken = getAuthToken();
|
||||
if (authToken) {
|
||||
req.options.headers['authorization'] = `Bearer ${authToken}`;
|
||||
}
|
||||
|
||||
next();
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export {withReaction} from '../coral-framework/hocs';
|
||||
@@ -36,18 +36,10 @@ class CommentBox extends React.Component {
|
||||
}
|
||||
};
|
||||
}
|
||||
static get defaultProps() {
|
||||
return {
|
||||
setCommentCountCache: () => {}
|
||||
};
|
||||
}
|
||||
postComment = ({body}) => {
|
||||
const {
|
||||
commentPostedHandler,
|
||||
postComment,
|
||||
setCommentCountCache,
|
||||
commentCountCache,
|
||||
isReply,
|
||||
assetId,
|
||||
parentId,
|
||||
addNotification,
|
||||
@@ -60,8 +52,6 @@ class CommentBox extends React.Component {
|
||||
...this.props.commentBox
|
||||
};
|
||||
|
||||
!isReply && setCommentCountCache(commentCountCache + 1);
|
||||
|
||||
// Execute preSubmit Hooks
|
||||
this.state.hooks.preSubmit.forEach((hook) => hook());
|
||||
|
||||
@@ -74,19 +64,12 @@ class CommentBox extends React.Component {
|
||||
|
||||
notifyForNewCommentStatus(addNotification, postedComment.status);
|
||||
|
||||
if (postedComment.status === 'REJECTED') {
|
||||
!isReply && setCommentCountCache(commentCountCache);
|
||||
} else if (postedComment.status === 'PREMOD') {
|
||||
!isReply && setCommentCountCache(commentCountCache);
|
||||
}
|
||||
|
||||
if (commentPostedHandler) {
|
||||
commentPostedHandler();
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
!isReply && setCommentCountCache(commentCountCache);
|
||||
});
|
||||
|
||||
this.setState({postedCount: this.state.postedCount + 1});
|
||||
@@ -167,6 +150,7 @@ class CommentBox extends React.Component {
|
||||
fill="commentInputDetailArea"
|
||||
registerHook={this.registerHook}
|
||||
unregisterHook={this.unregisterHook}
|
||||
isReply={isReply}
|
||||
inline
|
||||
/>}
|
||||
cancelButtonClicked={cancelButtonClicked}
|
||||
@@ -189,7 +173,6 @@ CommentBox.propTypes = {
|
||||
authorId: PropTypes.string.isRequired,
|
||||
isReply: PropTypes.bool.isRequired,
|
||||
canPost: PropTypes.bool,
|
||||
setCommentCountCache: PropTypes.func,
|
||||
};
|
||||
|
||||
const mapStateToProps = ({commentBox}) => ({commentBox});
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
.moderationLink {
|
||||
display: inline;
|
||||
|
||||
a {
|
||||
color: #679af3;
|
||||
text-decoration: none;
|
||||
|
||||
@@ -9,6 +9,10 @@ class ReplyBox extends Component {
|
||||
document.getElementById('replyText').focus();
|
||||
}
|
||||
|
||||
cancelReply = () => {
|
||||
this.props.setActiveReplyBox('');
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
styles,
|
||||
@@ -18,7 +22,6 @@ class ReplyBox extends Component {
|
||||
addNotification,
|
||||
parentId,
|
||||
commentPostedHandler,
|
||||
setActiveReplyBox,
|
||||
maxCharCount,
|
||||
charCountEnable
|
||||
} = this.props;
|
||||
@@ -28,7 +31,7 @@ class ReplyBox extends Component {
|
||||
charCountEnable={charCountEnable}
|
||||
commentPostedHandler={commentPostedHandler}
|
||||
parentId={parentId}
|
||||
cancelButtonClicked={setActiveReplyBox}
|
||||
cancelButtonClicked={this.cancelReply}
|
||||
addNotification={addNotification}
|
||||
authorId={authorId}
|
||||
assetId={assetId}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, {Component, PropTypes} from 'react';
|
||||
|
||||
import t from 'coral-framework/services/i18n';
|
||||
import styles from './IgnoredUsers.css';
|
||||
|
||||
export class IgnoredUsers extends Component {
|
||||
@@ -18,7 +18,7 @@ export class IgnoredUsers extends Component {
|
||||
<div>
|
||||
{
|
||||
users.length
|
||||
? <p>Because you ignored these, you do not see their comments.</p>
|
||||
? <p>{t('framework.because_you_ignored')}</p>
|
||||
: null
|
||||
}
|
||||
<dl className={styles.ignoredUserList}>
|
||||
@@ -29,7 +29,7 @@ export class IgnoredUsers extends Component {
|
||||
<dd className={styles.stopListening}>
|
||||
<a
|
||||
onClick={() => stopIgnoring({id})}
|
||||
className={styles.link}>Stop ignoring</a>
|
||||
className={styles.link}>{t('framework.stop_ignoring')}</a>
|
||||
</dd>
|
||||
</span>
|
||||
))
|
||||
|
||||
@@ -1,15 +1,25 @@
|
||||
.drawer {
|
||||
max-width: 700px;
|
||||
min-width: 400px;
|
||||
padding: 20px;
|
||||
min-width: 550px;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
right: -17px;
|
||||
bottom: 0;
|
||||
background-color: white;
|
||||
transition: transform 500ms ease-in-out;
|
||||
box-shadow: -3px 0px 4px 0px rgba(0,0,0,0.15);
|
||||
z-index: 10000;
|
||||
|
||||
.content {
|
||||
position: absolute;
|
||||
padding: 20px;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
overflow-y: scroll;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.closeButton {
|
||||
|
||||
@@ -6,7 +6,9 @@ const Drawer = ({children, handleClickOutside}) => {
|
||||
return (
|
||||
<div className={styles.drawer}>
|
||||
<div className={styles.closeButton} onClick={handleClickOutside}>×</div>
|
||||
{children}
|
||||
<div className={styles.content}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import React from 'react';
|
||||
import styles from './Tab.css';
|
||||
|
||||
export default ({children, tabId, active, onTabClick, cStyle = 'base'}) => (
|
||||
export default ({children, tabId, active, onTabClick, cStyle = 'base', ...props}) => (
|
||||
<li
|
||||
key={tabId}
|
||||
className={active ? styles[`${cStyle}--active`] : ''}
|
||||
className={`${active ? `${styles[`${cStyle}--active`]} coral-tab-active` : ''} coral-tab ${props.className}`}
|
||||
onClick={() => onTabClick(tabId)}
|
||||
>
|
||||
{children}
|
||||
</li>
|
||||
);
|
||||
|
||||
|
||||
@@ -17,13 +17,13 @@ class TabBar extends React.Component {
|
||||
const {children, activeTab, cStyle = 'base'} = this.props;
|
||||
return (
|
||||
<div>
|
||||
<ul className={`${styles.base} ${cStyle ? styles[cStyle] : ''}`}>
|
||||
<ul className={`${styles.base} ${cStyle ? styles[cStyle] : ''} ${this.props.className}`}>
|
||||
{React.Children.toArray(children)
|
||||
.filter((child) => !child.props.restricted)
|
||||
.map((child, tabId) =>
|
||||
React.cloneElement(child, {
|
||||
tabId,
|
||||
active: tabId === activeTab,
|
||||
active: child.props.id === activeTab,
|
||||
onTabClick: this.handleClickTab,
|
||||
cStyle
|
||||
})
|
||||
|
||||
+4
-2
@@ -8,7 +8,7 @@ summary:
|
||||
|
||||
[](https://circleci.com/gh/coralproject/talk)
|
||||
|
||||
Online comments are broken. Our open-source Talk tool rethinks how moderation, comment display, and conversation function, creating the opportunity for safer, smarter discussions around your work. [Read more about Talk here.](https://coralproject.net/products/talk.html)
|
||||
Online comments are broken. Mozilla's open-source, plugin-based tool Talk rethinks how moderation, comment display, and conversation function online, creating the opportunity for better, smarter discussions designed to fit your needs. [Read more about Talk here.](https://coralproject.net/products/talk.html)
|
||||
|
||||
Third party licenses are available via the `/client/3rdpartylicenses.txt`
|
||||
endpoint when the server is running with built assets.
|
||||
@@ -30,6 +30,8 @@ See our [Contribution Guide](https://github.com/coralproject/talk/blob/master/CO
|
||||
|
||||
- iPad
|
||||
- iPad Pro
|
||||
- iPhone 7 Plus
|
||||
- iPhone 7
|
||||
- iPhone 6 Plus
|
||||
- iPhone 6
|
||||
- iPhone 5
|
||||
@@ -42,7 +44,7 @@ See our [Contribution Guide](https://github.com/coralproject/talk/blob/master/CO
|
||||
|
||||
### Android Devices
|
||||
|
||||
- Galaxy S5
|
||||
- Galaxy S5x
|
||||
- Nexus 5X
|
||||
- Nexus 6P
|
||||
|
||||
|
||||
@@ -340,6 +340,12 @@ const edit = async (context, {id, asset_id, edit: {body}}) => {
|
||||
// Execute the edit.
|
||||
const comment = await CommentsService.edit(id, context.user.id, {body, status});
|
||||
|
||||
if (context.pubsub) {
|
||||
|
||||
// Publish the edited comment via the subscription.
|
||||
context.pubsub.publish('commentEdited', comment);
|
||||
}
|
||||
|
||||
return comment;
|
||||
};
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ const Comment = {
|
||||
},
|
||||
async editing(comment, _, {loaders: {Settings}}) {
|
||||
const settings = await Settings.load();
|
||||
const editableUntil = new Date(Number(comment.created_at) + settings.editCommentWindowLength);
|
||||
const editableUntil = new Date(Number(new Date(comment.created_at)) + settings.editCommentWindowLength);
|
||||
return {
|
||||
edited: comment.edited,
|
||||
editableUntil: editableUntil
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
const Subscription = {
|
||||
commentAdded(comment) {
|
||||
return comment;
|
||||
},
|
||||
commentEdited(comment) {
|
||||
return comment;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -25,6 +25,11 @@ const setupFunctions = plugins.get('server', 'setupFunctions').reduce((acc, {plu
|
||||
filter: (comment) => comment.asset_id === args.asset_id
|
||||
},
|
||||
}),
|
||||
commentEdited: (options, args) => ({
|
||||
commentEdited: {
|
||||
filter: (comment) => comment.asset_id === args.asset_id
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -148,6 +148,9 @@ enum ACTION_TYPE {
|
||||
# CommentsQuery allows the ability to query comments by a specific methods.
|
||||
input CommentsQuery {
|
||||
|
||||
# Author of the comments
|
||||
author_id: ID
|
||||
|
||||
# Current status of a comment. Requires the `ADMIN` role.
|
||||
statuses: [COMMENT_STATUS!]
|
||||
|
||||
@@ -880,6 +883,7 @@ type RootMutation {
|
||||
|
||||
type Subscription {
|
||||
commentAdded(asset_id: ID!): Comment
|
||||
commentEdited(asset_id: ID!): Comment
|
||||
}
|
||||
|
||||
################################################################################
|
||||
|
||||
+41
-13
@@ -3,17 +3,17 @@ en:
|
||||
are_you_sure: "Are you sure you would like to ban {0}?"
|
||||
ban_user: "Ban User?"
|
||||
banned_user: "Banned User"
|
||||
cancel: Cancel
|
||||
cancel: "Cancel"
|
||||
note: "Note: Banning this user will also place this comment in the Rejected queue."
|
||||
yes_ban_user: "Yes, Ban User"
|
||||
bio_offensive: "This bio is offensive"
|
||||
cancel: Cancel
|
||||
cancel: "Cancel"
|
||||
characters_remaining: "characters remaining"
|
||||
comment:
|
||||
anon: Anonymous
|
||||
anon: "Anonymous"
|
||||
ban_user: "Ban User"
|
||||
comment: "Post a comment"
|
||||
flagged: flagged
|
||||
flagged: "flagged"
|
||||
view_context: "View context"
|
||||
comment_box:
|
||||
post: "Post"
|
||||
@@ -87,7 +87,7 @@ en:
|
||||
custom_css_url_desc: "URL of a CSS stylesheet that will override default Embed Stream styles. Can be internal or external."
|
||||
dashboard: Dashboard
|
||||
days: Days
|
||||
description: "As an admin you may customize the settings for the comment stream for this asset"
|
||||
description: "As an admin, you can customize the settings for the comment stream for this story:"
|
||||
domain_list_text: "Enter the domains you would like to permit for Talk e.g. your local staging and production environments (ex. localhost:3000 staging.domain.com domain.com)."
|
||||
domain_list_title: "Permitted Domains"
|
||||
edit_comment_timeframe_heading: "Edit Comment Timeframe"
|
||||
@@ -101,13 +101,13 @@ en:
|
||||
enable_premod: "Enable Premoderation"
|
||||
enable_premod_description: "Moderators must approve any comment before its published."
|
||||
enable_premod_links_description: "Moderators must approve any comment containing a link before its published."
|
||||
enable_questionbox: "Ask readers a question"
|
||||
enable_questionbox: "Ask Readers a Question"
|
||||
enable_questionbox_description: "This question will appear at the top of this comment stream. Ask readers about a certain issue in the article or pose discussion questions etc."
|
||||
hours: Hours
|
||||
include_comment_stream: "Include Comment Stream Description for Readers"
|
||||
include_comment_stream_desc: "Write a message to be added to the top of your comment stream. Pose a topic include community guidelines etc."
|
||||
include_text: "Include your text here."
|
||||
include_question_here: "Write your question here."
|
||||
include_question_here: "Write your question here:"
|
||||
moderate: Moderate
|
||||
moderation_settings: "Moderation Settings"
|
||||
open: "Open"
|
||||
@@ -126,7 +126,20 @@ en:
|
||||
title: "Configure Comment Stream"
|
||||
weeks: Weeks
|
||||
wordlist: "Banned Words"
|
||||
continue: Continue
|
||||
continue: "Continue"
|
||||
createdisplay:
|
||||
check_the_form: "Invalid Form. Please check the fields"
|
||||
continue: "Continue with the same Facebook username"
|
||||
error_create: "Error when changing username"
|
||||
fake_comment_body: "This is an example comment. Readers can share their thoughts and opinions with newsrooms in the comments section."
|
||||
fake_comment_date: "1 minute ago"
|
||||
if_you_dont_change_your_name: "If you don't change your username at this step your Facebook display name will appear alongside of all your comments."
|
||||
required_field: "Required field"
|
||||
save: Save
|
||||
special_characters: "Usernames can contain letters numbers and _ only"
|
||||
username: Username
|
||||
write_your_username: "Edit your username"
|
||||
your_username: "Your username appears on every comment you post."
|
||||
dashboard:
|
||||
auto_update: "Data automatically updates every five minutes or when you Reload."
|
||||
comment_count: comments
|
||||
@@ -147,6 +160,17 @@ en:
|
||||
second: "second"
|
||||
seconds_plural: "seconds"
|
||||
unexpected_error: "Unexpected error while saving changes. Sorry!"
|
||||
email:
|
||||
confirm:
|
||||
has_been_requested: "A email confirmation has been requested for the following account:"
|
||||
to_confirm: "To confirm the account, please visit the following link:"
|
||||
confirm_email: "Confirm Email"
|
||||
if_you_did_not: "If you did not request this, you can safely ignore this email."
|
||||
subject: "Email Confirmation"
|
||||
password_reset:
|
||||
we_received_a_request: "We received a request to reset your password. If you did not request this change, you can ignore this email."
|
||||
if_you_did: "If you did,"
|
||||
please_click: "please click here to reset password"
|
||||
embedlink:
|
||||
copy: "Copy to Clipboard"
|
||||
error:
|
||||
@@ -178,11 +202,13 @@ en:
|
||||
flag_reason: "Reason for reporting (Optional)"
|
||||
flag_username: "Report username"
|
||||
framework:
|
||||
banned_account_msg: "Your account is currently suspended. This means that you cannot Like Report or write comments. Please contact us if you have any questions."
|
||||
banned_account_header: "Your account is currently banned."
|
||||
banned_account_body: "This means that you cannot Like, Report, or write comments."
|
||||
because_you_ignored: "Because you ignored the following commenters, their comments are hidden."
|
||||
comment: comment
|
||||
comment_is_ignored: "This comment is hidden because you ignored this user."
|
||||
comments: comments
|
||||
configure_stream: "Configure Stream"
|
||||
configure_stream: "Configure"
|
||||
content_not_available: "This content is not available"
|
||||
edit_name:
|
||||
button: Submit
|
||||
@@ -195,9 +221,11 @@ en:
|
||||
new_count: "View {0} new {1}"
|
||||
profile: Profile
|
||||
show_all_comments: "Show all comments"
|
||||
stop_ignoring: "Stop ignoring"
|
||||
success_bio_update: "Your biography has been updated"
|
||||
success_name_update: "Your username has been updated"
|
||||
success_update_settings: "The changes you have made have been applied to the comment stream on this article"
|
||||
view_all_replies_unknown_number: "view all replies"
|
||||
view_all_replies: "view {0} replies"
|
||||
view_all_replies_initial: "view all {0} replies"
|
||||
view_more_comments: "view more comments"
|
||||
@@ -238,8 +266,8 @@ en:
|
||||
reject: "Reject"
|
||||
rejected: "Rejected"
|
||||
select_stream: "Select Stream"
|
||||
shift_key: ⇧
|
||||
shortcuts: Shortcuts
|
||||
shift_key: "⇧"
|
||||
shortcuts: "Shortcuts"
|
||||
show_shortcuts: "Show Shortcuts"
|
||||
singleview: "Toggle single comment edit view"
|
||||
spam_ads: Spam/Ads
|
||||
@@ -296,7 +324,7 @@ en:
|
||||
bio: bio
|
||||
cancel: "Cancel"
|
||||
days: "{0} days"
|
||||
description_0: "Would you like to temporarily ban this user because of their {0}? Doing so will temporarily hide their comments until they rewrite their {0}."
|
||||
description_0: "Would you like to temporarily suspend this user because of their {0}? Doing so will temporarily hide their comments until they rewrite their {0}."
|
||||
description_1: "Suspending this user will temporarily disable their account and hide all of their comments on the site."
|
||||
description_notify: "Suspending this user will temporarily disable their account and hide all of their comments on the site."
|
||||
description_reject: "Would you like to temporarily ban this user because of their {0}? Doing so will temporarily hide their comments until they rewrite their {0}."
|
||||
|
||||
+156
-166
@@ -4,8 +4,7 @@ es:
|
||||
ban_user: "¿Quieres suspender el Usuario?"
|
||||
banned_user: "Usuario Suspendido"
|
||||
cancel: Cancelar
|
||||
note: "Nota: Suspender a este usuario también va a colocar este comentario en\
|
||||
\ la cola de Rechazados."
|
||||
note: "Nota: Suspender a este usuario también va a colocar este comentario en la cola de Rechazados."
|
||||
yes_ban_user: "Si, Suspender al usuario"
|
||||
bio_offensive: "Esta biografia es ofensiva"
|
||||
cancel: Cancelar
|
||||
@@ -23,22 +22,16 @@ es:
|
||||
comment: "Publicar un comentario"
|
||||
name: "Nombre"
|
||||
comment_post_notif: "Tu comentario ha sido publicado."
|
||||
comment_post_notif_premod: "Gracias por el comentario. Nuestro equipo de moderació\
|
||||
n va a revisarlo muy pronto."
|
||||
comment_post_banned_word: "Tu comentario contiene una o más palabras que no está\
|
||||
n permitidas en nuestro espacio, por lo que no será publicado. Si crees que\
|
||||
\ es un error, por favor contacta a nuestro equipo de moderación."
|
||||
comment_post_notif_premod: "Gracias por el comentario. Nuestro equipo de moderación va a revisarlo muy pronto."
|
||||
comment_post_banned_word: "Tu comentario contiene una o más palabras que no están permitidas en nuestro espacio, por lo que no será publicado. Si crees que es un error, por favor contacta a nuestro equipo de moderación."
|
||||
characters_remaining: "carácteres restantes"
|
||||
comment_is_best: "Este comentario es uno de los mejores"
|
||||
comment_offensive: "Este comentario es ofensivo"
|
||||
comment_singular: Comentario
|
||||
comment_plural: Comentarios
|
||||
comment_post_banned_word: "Tu comentario contiene una o más palabras que no está\
|
||||
n permitidas en nuestro espacio, por lo que no será publicado. Si crees que es\
|
||||
\ un error, por favor contacta a nuestro equipo de moderación."
|
||||
comment_post_banned_word: "Tu comentario contiene una o más palabras que no están permitidas en nuestro espacio, por lo que no será publicado. Si crees que es un error, por favor contacta a nuestro equipo de moderación."
|
||||
comment_post_notif: "Tu comentario ha sido publicado."
|
||||
comment_post_notif_premod: "Gracias por el comentario. Nuestro equipo de moderació\
|
||||
n va a revisarlo muy pronto."
|
||||
comment_post_notif_premod: "Gracias por el comentario. Nuestro equipo de moderación va a revisarlo muy pronto."
|
||||
community:
|
||||
account_creation_date: "Fecha de creación de la cuenta"
|
||||
active: Activa
|
||||
@@ -57,8 +50,7 @@ es:
|
||||
newsroom_role: "Rol en la redacción"
|
||||
no_flagged_accounts: "No hay ninguna cuenta reportada en este momento."
|
||||
no_results: "No se encontraron usuarios con ese nombre o correo."
|
||||
note: "Nota: Suspender a este usuario no le va a permitir (al usuario) borrar\
|
||||
\ ni editar ni comentar."
|
||||
note: "Nota: Suspender a este usuario no le va a permitir (al usuario) borrar ni editar ni comentar."
|
||||
offensive: "Este comentario es ofensivo"
|
||||
other: Otro
|
||||
people: Gente
|
||||
@@ -71,21 +63,15 @@ es:
|
||||
yes_ban_user: "Si, Suspendan el usuario"
|
||||
configure:
|
||||
apply: Aplicar
|
||||
banned_word_text: "Comentarios que contengan estas palabras o frases, en mayusculas\
|
||||
\ o minúsculas, serán automáticamente eliminados del hilo de comentario. Escribir\
|
||||
\ una palabra y apretar Enter o Tabulador para agregarla. O pueden pegar una\
|
||||
\ lista de palabras separadas por coma."
|
||||
banned_word_text: "Comentarios que contengan estas palabras o frases, en mayusculas o minúsculas, serán automáticamente eliminados del hilo de comentario. Escribir una palabra y apretar Enter o Tabulador para agregarla. O pueden pegar una lista de palabras separadas por coma."
|
||||
banned_words_title: "Lista de palabras prohibidas"
|
||||
close: "Cerrar"
|
||||
close_after: "Cerrar comentarios luego de"
|
||||
close_stream: "Cerrar comentarios"
|
||||
close_stream_configuration: "Este hilo de comentario está en este momento cerrado.\
|
||||
\ Al abrirlo, nuevos comentarios serán publicados y mostrados."
|
||||
closed_comments_desc: "Escribe un mensaje que será mostrado cuando los comentarios\
|
||||
\ estén cerrados y no se acepten más comentarios."
|
||||
close_stream_configuration: "Este hilo de comentario está en este momento cerrado. Al abrirlo, nuevos comentarios serán publicados y mostrados."
|
||||
closed_comments_desc: "Escribe un mensaje que será mostrado cuando los comentarios estén cerrados y no se acepten más comentarios."
|
||||
closed_comments_label: "Escribe un mensaje..."
|
||||
closed_stream_settings: "Mensaje que se muestra cuando no se aceptan más comentarios\
|
||||
\ en el articulo"
|
||||
closed_stream_settings: "Mensaje que se muestra cuando no se aceptan más comentarios en el articulo"
|
||||
comment_count_error: "Por favor escribir un número válido"
|
||||
comment_count_header: "Limitar el largo del comentario"
|
||||
comment_count_text_post: carácteres
|
||||
@@ -95,155 +81,155 @@ es:
|
||||
comment_stream_will_close: "El hilo de comentarios se cerrara"
|
||||
community: Comunidad
|
||||
configure: Configurar
|
||||
copy_and_paste: "Copiar y pegar el código de más abajo en tu CMS para colocar\
|
||||
\ la caja de comentarios en tus artículos"
|
||||
copy_and_paste: "Copiar y pegar el código de más abajo en tu CMS para colocar la caja de comentarios en tus artículos"
|
||||
custom_css_url: "URL CSS a medida"
|
||||
custom_css_url_desc: "URL de una hoja de estilo que va a sobrescribir los estilos\
|
||||
\ por defecto del hilo de comentarios. Puede ser interna o externa."
|
||||
custom_css_url_desc: "URL de una hoja de estilo que va a sobrescribir los estilos por defecto del hilo de comentarios. Puede ser interna o externa."
|
||||
dashboard: Panel
|
||||
days: Días
|
||||
description: "Como Administrador/a puedes modificar la configuración de los comentarios\
|
||||
\ en este artículo"
|
||||
domain_list_text: "Agrega dominios permitidos a Talk, por ejemplo tu localhost,\
|
||||
\ staging y ambientes de producción (ej. localhost:3000, staging.domain.com,\
|
||||
\ domain.com)."
|
||||
description: "Como Administrador/a puedes modificar la configuración de los comentarios en este artículo"
|
||||
domain_list_text: "Agrega dominios permitidos a Talk, por ejemplo tu localhost, staging y ambientes de producción (ej. localhost:3000, staging.domain.com, domain.com)."
|
||||
domain_list_title: "Dominios Permitidos"
|
||||
edit_comment_timeframe_heading: "Periodo de Tiempo para Edición del Comentario"
|
||||
edit_comment_timeframe_text_pre: "Los comentaristas tendrán"
|
||||
edit_comment_timeframe_text_post: "segundos para editar sus comentarios."
|
||||
embed_comment_stream: "Colocar Hilo de Comentarios"
|
||||
enable_premod_links_text: "Los moderadores deben aprobar todo comentario que contenga\
|
||||
\ un enlace antes de que sea publicado."
|
||||
enable_premod_links_text: "Los moderadores deben aprobar todo comentario que contenga un enlace antes de que sea publicado."
|
||||
enable_pre_moderation: "Permitir pre-moderación"
|
||||
enable_pre_moderation_text: "Los moderadores deben aprobar todo comentario antes\
|
||||
\ que sea publicado."
|
||||
enable_pre_moderation_text: "Los moderadores deben aprobar todo comentario antes que sea publicado."
|
||||
enable_premod_links: "Pre-Moderar Comentarios que contienen Enlaces"
|
||||
enable_premod: "Activar Pre Moderación"
|
||||
enable_premod_description: "Los y las moderadoras deben aprobar cualquier comentario\
|
||||
\ antes de su publicación"
|
||||
enable_premod_links_description: "Los y las moderadoras deben aprobar cualquier\
|
||||
\ comentario que contengan enlaces antes de su publicación."
|
||||
enable_premod_description: "Los y las moderadoras deben aprobar cualquier comentario antes de su publicación"
|
||||
enable_premod_links_description: "Los y las moderadoras deben aprobar cualquier comentario que contengan enlaces antes de su publicación."
|
||||
enable_questionbox: "Hacer una pregunta a los y las lectoras."
|
||||
enable_questionbox_description: "Esta pregunta aparecerá en la parte de arriba\
|
||||
\ del hilo de comentarios."
|
||||
enable_questionbox_description: "Esta pregunta aparecerá en la parte de arriba del hilo de comentarios."
|
||||
hours: Horas
|
||||
include_comment_stream: "Incluir Descripción de Hilo de Comentarios para Lectores"
|
||||
include_comment_stream_desc: "Escribir un mensaje para ser incluido al principio\
|
||||
\ del hilo de comentarios. Un tema planteado podría ser la guía de comunidad,\
|
||||
\ etc."
|
||||
include_comment_stream_desc: "Escribir un mensaje para ser incluido al principio del hilo de comentarios. Un tema planteado podría ser la guía de comunidad, etc."
|
||||
include_text: "Agregar tu texto aquí."
|
||||
include_question_here: "Escribir la pregunta aquí."
|
||||
moderate: Moderar
|
||||
moderation_settings: "Configuración de la Moderación"
|
||||
open: "Abrir"
|
||||
open_stream: "Abrir Hilo de Comentarios"
|
||||
open_stream_configuration: "Este hilo de comentarios está abierto. Al cerrarlo\
|
||||
\ no se podrán publicar nuevos comentarios pero todos los comentarios anteriores\
|
||||
\ aún serán mostrados."
|
||||
open_stream_configuration: "Este hilo de comentarios está abierto. Al cerrarlo no se podrán publicar nuevos comentarios pero todos los comentarios anteriores aún serán mostrados."
|
||||
require_email_verification: "Necesita confirmación su correo"
|
||||
require_email_verification_text: "Nuevos usuarios deben confirmar sus correos\
|
||||
\ antes de comentar"
|
||||
require_email_verification_text: "Nuevos usuarios deben confirmar sus correos antes de comentar"
|
||||
save_changes: "Guardar Cambios"
|
||||
shortcuts: Atajos
|
||||
sign_out: "Desconectar"
|
||||
stories: Artículos
|
||||
stream_settings: "Configuración de Comentarios"
|
||||
suspect_word_title: "Lista de palabras sospechosas"
|
||||
suspect_word_text: "Comentarios que contengan estas palabras o frases, considerando\
|
||||
\ mayusculas y minúsculas, serán automáticamente destacadas en los comentarios\
|
||||
\ publicados. Escribir una palabra y apretar Enter o Tabulador para agregarla.\
|
||||
\ O pegar una lista de palabras separadas por coma."
|
||||
suspect_word_text: "Comentarios que contengan estas palabras o frases, considerando mayusculas y minúsculas, serán automáticamente destacadas en los comentarios publicados. Escribir una palabra y apretar Enter o Tabulador para agregarla. O pegar una lista de palabras separadas por coma."
|
||||
tech_settings: "Configuración Técnica"
|
||||
title: "Configurar los comentarios"
|
||||
weeks: Semanas
|
||||
wordlist: "Palabras Suspendidas"
|
||||
continue: Continuar
|
||||
continue: "Continuar"
|
||||
createdisplay:
|
||||
check_the_form: "Formulario Inválido. Por favor verifica los campos"
|
||||
continue: "Continuar con el mismo nombre que aparece en Facebook"
|
||||
error_create: "Hubo un error al cambiar el nombre de usuario"
|
||||
fake_comment_body: "Este es un comentario de ejemplo. Las lectoras pueden compartir sus ideas y opiniones con los periodistas en la sección de comentarios."
|
||||
fake_comment_date: "hace un minuto"
|
||||
if_you_dont_change_your_name: "Si no modificas tu nombre de usuario en este paso tu nombre de Facebook aparecerá al lado de cada comentario que publiques."
|
||||
required_field: "Campo requerido"
|
||||
save: "Guardar"
|
||||
special_characters: "Los nombres sólo pueden contener letras números y _"
|
||||
username: "Nombre"
|
||||
write_your_username: "Edita tu nombre"
|
||||
your_username: "Tu nombre aparece en cada comentario que publiques."
|
||||
dashboard:
|
||||
auto_update: "Los datos se actualizan automáticamente cada cinco minutos o cuando\
|
||||
\ refresca el navegador."
|
||||
comment_count: comentarios
|
||||
flags: Reportes
|
||||
auto_update: "Los datos se actualizan automáticamente cada cinco minutos o cuando refresca el navegador."
|
||||
comment_count: "comentarios"
|
||||
flags: "Reportes"
|
||||
most_flags: "Artículos con la mayor cantidad de reportes"
|
||||
most_conversations: "Artículos con las mayores conversaciones"
|
||||
next_update: "{0} minutos hasta la próxima actualización."
|
||||
no_activity: "¡No han habido comentarios en ningún lado en los últimos 5 minutos."
|
||||
no_flags: "¡No ha habido ningún reporte en los últimos 5 minutos! Bravo!"
|
||||
no_likes: "¡No ha habido ningún 'me gusta' en los últimos 5 minutos. Todo tranquilo."
|
||||
done: Hecho
|
||||
done: "Hecho"
|
||||
edit_comment:
|
||||
body_input_label: "Editar este comentario"
|
||||
save_button: "Guardar cambios"
|
||||
edit_window_expired: "No se puede editar este comentario. El periodo de edición\
|
||||
\ ya ha concluido. Podrías publicar uno nuevo :-)"
|
||||
edit_window_expired: "No se puede editar este comentario. El periodo de edición ya ha concluido. Podrías publicar uno nuevo :-)"
|
||||
edit_window_expired_close: "Cerrar"
|
||||
edit_window_timer_prefix: "Ventana Edición:"
|
||||
second: "segundo"
|
||||
seconds_plural: "segundos"
|
||||
unexpected_error: "Lo siento. Ha habido un error no previsto al guardar los cambios."
|
||||
email:
|
||||
confirm:
|
||||
has_been_requested: "Un correo de confirmación ha sido pedido para la siguiente cuenta:"
|
||||
to_confirm: "Para confirmar la cuenta, por favor visitar el siguiente enlace:"
|
||||
confirm_email: "Confirmar Correo"
|
||||
if_you_did_not: "Si no has pedido esto, puedes ignorar el correo."
|
||||
subject: "Confirmación de Correo"
|
||||
password_reset:
|
||||
we_received_a_request: "Recibimos un pedido para resetear su contraseña. Si no ha pedido ese cambio, por favor ignorar el correo. "
|
||||
if_you_did: "Si lo hizo,"
|
||||
please_click: "por favor cliquea aqui para resetear la contraseña."
|
||||
embedlink:
|
||||
copy: "Copiar al portapapeles"
|
||||
error:
|
||||
COMMENT_TOO_SHORT: "Tu comentario debe tener algo escrito"
|
||||
NOT_AUTHORIZED: "Acción no autorizada."
|
||||
NO_SPECIAL_CHARACTERS: "Los nombres pueden contener letras números y _"
|
||||
PASSWORD_LENGTH: "Contraseña es muy corta"
|
||||
PROFANITY_ERROR: "Los nombres no pueden contener blasfemias. Por favor contacte\
|
||||
\ al o la administradora si cree que esto es un error"
|
||||
USERNAME_IN_USE: "Este nombre ya está siendo usado."
|
||||
USERNAME_REQUIRED: "Debe ingresar un nombre"
|
||||
EDIT_WINDOW_ENDED: "No puedes editar este comentario. El tiempo de edición ha\
|
||||
\ expirado."
|
||||
EDIT_USERNAME_NOT_AUTHORIZED: "No tiene permiso para editar el nombre de usuario."
|
||||
EMAIL_IN_USE: "Este correo se encuentra en uso"
|
||||
EMAIL_REQUIRED: "Se requiere un correo"
|
||||
LOGIN_MAXIMUM_EXCEEDED: "Ha realizado demasiados intentos fallidos de usar la\
|
||||
\ contraseña. Por favor espere."
|
||||
PASSWORD_REQUIRED: "Debe ingresar la contraseña"
|
||||
COMMENTING_CLOSED: "Los comentarios ya estan cerrados"
|
||||
NOT_FOUND: "Recurso no encontrado"
|
||||
INVALID_ASSET_URL: "La URL del articulo no es valida"
|
||||
email: "No es un correo válido"
|
||||
confirm_password: "Las contraseñas no coinciden. Inténtelo nuevamente"
|
||||
network_error: "Error al conectar con el servidor. Compruebe su conexión a Internet\
|
||||
\ y vuelva a intentarlo."
|
||||
edit_window_ended: "No puedes editar este comentario. El tiempo de edición ha expirado."
|
||||
edit_username_not_authorized: "No tiene permiso para editar el nombre de usuario."
|
||||
email: "No es un correo válido"
|
||||
email_in_use: "Este correo se encuentra en uso"
|
||||
email_not_verified: "Correo {0} no confirmado."
|
||||
email_password: "Correo y/o contraseña incorrecta."
|
||||
email_required: "Se requiere un correo"
|
||||
email_username_in_use: "Correo o nombre en uso."
|
||||
INVALID_ASSET_URL: "La URL del articulo no es valida"
|
||||
login_maximum_exceeded: "Ha realizado demasiados intentos fallidos de usar la contraseña. Por favor espere."
|
||||
network_error: "Error al conectar con el servidor. Compruebe su conexión a Internet y vuelva a intentarlo."
|
||||
NOT_AUTHORIZED: "Acción no autorizada."
|
||||
NOT_FOUND: "Recurso no encontrado"
|
||||
NO_SPECIAL_CHARACTERS: "Los nombres pueden contener letras números y _"
|
||||
organization_name: "El nombre de la organización debe contener letras y/o números."
|
||||
password: "La contraseña debe tener por lo menos 8 caracteres"
|
||||
PASSWORD_LENGTH: "Contraseña es muy corta"
|
||||
PASSWORD_REQUIRED: "Debe ingresar la contraseña"
|
||||
PROFANITY_ERROR: "Los nombres no pueden contener blasfemias. Por favor contacte al o la administradora si cree que esto es un error"
|
||||
username: "Los nombres pueden contener letras números y _"
|
||||
USERNAME_IN_USE: "Este nombre ya está siendo usado."
|
||||
USERNAME_REQUIRED: "Debe ingresar un nombre"
|
||||
flag_comment: "Reportar este comentario"
|
||||
flag_reason: "Razón por la que hacer este reporte (Opcional)"
|
||||
flag_username: "Reportar el nombre de usuario"
|
||||
framework:
|
||||
banned_account_msg: "Tu cuenta se encuentra suspendida. Esto significa que no\
|
||||
\ puedes gustar, marcar o escribir comentarios."
|
||||
comment: comentario
|
||||
banned_account_header: "Tu cuenta se encuentra suspendida."
|
||||
banned_account_body: "Esto significa que no puedes gustar, marcar o escribir comentarios."
|
||||
comment: "comentario"
|
||||
comment_is_ignored: "Este comentario está escondido porque has ignorado al usuario."
|
||||
comments: comentarios
|
||||
comments: "comentarios"
|
||||
configure_stream: "Configurar Hilo de Comentarios"
|
||||
content_not_available: "Este contenido no se encuentra disponible"
|
||||
edit_name:
|
||||
button: Enviar
|
||||
button: "Enviar"
|
||||
error: "Nombres de usuarios pueden solamente incluir letras, números y _"
|
||||
label: "Nuevo Nombre"
|
||||
msg: "Tu cuenta está 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. Contáctanos si tienes alguna pregunta."
|
||||
msg: "Tu cuenta está 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. Contáctanos si tienes alguna pregunta."
|
||||
ignored_users: "Usuarios ignorados"
|
||||
my_comments: "Mis Comentarios"
|
||||
my_profile: "Mi perfil"
|
||||
new_count: "Ver {0} {1} nuevo"
|
||||
profile: Perfil
|
||||
show_all_comments: "Mostrar todos los comentarios"
|
||||
stop_ignoring: "No ignorar más"
|
||||
success_bio_update: "Tu biografia fue actualizada"
|
||||
success_name_update: "Tu nombre de usuario ha sido actualizado"
|
||||
success_update_settings: "La configuración de este articulo fue actualizada"
|
||||
view_all_replies_unknown_number: "ver todas las respuestas"
|
||||
view_all_replies: "ver {0} respuestas"
|
||||
view_all_replies_initial: "ver todas las {0} respuestas"
|
||||
view_more_comments: "Ver más comentarios"
|
||||
view_reply: "ver respuesta"
|
||||
from_settings_page: "Desde la página de configuración puedes ver tu historia de\
|
||||
\ comentarios."
|
||||
from_settings_page: "Desde la página de configuración puedes ver tu historia de comentarios."
|
||||
like: Me gusta
|
||||
loading_results: "Cargando Resultados"
|
||||
marketing: "Esto parece una propaganda"
|
||||
@@ -300,78 +286,88 @@ es:
|
||||
profile_settings: "Configuración del Perfil"
|
||||
reply: Responder
|
||||
report: Reportar
|
||||
report_notif: "Gracias por reportar este comentario. Nuestro equipo de moderació\
|
||||
n ha sido notificado y muy pronto lo va a revisar."
|
||||
report_notif: "Gracias por reportar este comentario. Nuestro equipo de moderación ha sido notificado y muy pronto lo va a revisar."
|
||||
report_notif_remove: "Tu reporte ha sido eliminado."
|
||||
reported: Reportado
|
||||
reported: "Reportado"
|
||||
set_best: "Etiquetar como el mejor"
|
||||
settings:
|
||||
all_comments: "Todos los comentarios"
|
||||
from_settings_page: "Desde la página de configuración puedes ver tu historial\
|
||||
\ de comentarios."
|
||||
from_settings_page: "Desde la página de configuración puedes ver tu historial de comentarios."
|
||||
my_comment_history: "Mi historial de comentarios"
|
||||
profile: Perfil
|
||||
profile_settings: "Configuración del perfil"
|
||||
sign_in: "Entrar"
|
||||
to_access: "para acceder al perfil"
|
||||
user_no_comment: "No has dejado aún ningún comentario. ¡Únete a la conversació\
|
||||
n!"
|
||||
user_no_comment: "No has dejado aún ningún comentario. ¡Únete a la conversación!"
|
||||
sign_in:
|
||||
email_verify_cta: "Por favor confirme su correo."
|
||||
request_new_verify_email: "Enviar otro correo:"
|
||||
verify_email: "¡Gracias por crear una cuenta! Le enviamos un correo a la dirección que dio para confirmar su cuenta."
|
||||
verify_email2: "Debe confirmarla antes de poder involucrarse en la comunidad."
|
||||
not_you: "¿No eres tu?"
|
||||
logged_in_as: "Entraste como"
|
||||
facebook_sign_in: "Entrar con Facebook"
|
||||
facebook_sign_up: "Registrarse con Facebook"
|
||||
logout: "Salir"
|
||||
sign_in: "Entrar"
|
||||
sign_in_to_join: "Entrar para unirte a la conversación"
|
||||
or: "O"
|
||||
email: "Dirección de Correo"
|
||||
password: "Contraseña"
|
||||
forgot_your_pass: "¿Has olvidado tu contraseña?"
|
||||
need_an_account: "¿Necesitas una cuenta?"
|
||||
register: "Registrar"
|
||||
sign_up: "Registro"
|
||||
confirm_password: "Confirmar Contraseña"
|
||||
username: "Nombre"
|
||||
already_have_an_account: "¿Ya tienes una cuenta?"
|
||||
recover_password: "Recuperar la contraseña"
|
||||
email_in_use: "Este correo se encuentra en uso"
|
||||
email_or_username_in_use: "Este correo ó nombre de usuario se encuentran en uso"
|
||||
required_field: "Este campo es requerido"
|
||||
passwords_dont_match: "Las contraseñas no coinciden."
|
||||
special_characters: "Los nombres pueden contener letras, números y _"
|
||||
sign_in_to_comment: "Entrar para comentar"
|
||||
temporarily_suspended: "De acuerdo con la guía de la comunidad de {0}, su cuenta ha sido temporalmente suspendida. Por favor unirse a la conversación {1}."
|
||||
check_the_form: "Formulario Inválido. Por favor, completa los campos"
|
||||
step_1_header: "Reportar un problema"
|
||||
step_2_header: "Ayúdanos a comprender"
|
||||
step_3_header: "Gracias por tu participación"
|
||||
stream:
|
||||
temporarily_suspended: "De acuerdo con la guía de la comunidad de {0}, su cuenta\
|
||||
\ ha sido temporalmente suspendida. Por favor unirse a la conversación {1}."
|
||||
temporarily_suspended: "De acuerdo con la guía de la comunidad de {0}, su cuenta ha sido temporalmente suspendida. Por favor unirse a la conversación {1}."
|
||||
streams:
|
||||
all: Todos
|
||||
article: Artículo
|
||||
closed: Cerrado
|
||||
empty_result: "Ningún articulo coincide con esta búsqueda. Intente expandir su\
|
||||
\ búsqueda."
|
||||
all: "Todos"
|
||||
article: "Artículo"
|
||||
closed: "Cerrado"
|
||||
empty_result: "Ningún articulo coincide con esta búsqueda. Intente expandir su búsqueda."
|
||||
filter_streams: "Filtrar Hilos"
|
||||
newest: Más nuevo
|
||||
oldest: Más antiguo
|
||||
open: Abierto
|
||||
newest: "Más nuevo"
|
||||
oldest: "Más antiguo"
|
||||
open: "Abierto"
|
||||
pubdate: "Fecha Publicación"
|
||||
search: Buscar
|
||||
search: "Buscar"
|
||||
sort_by: "Ordenar por"
|
||||
status: "Estado del Hilo"
|
||||
stream_status: "Estado del Hilo"
|
||||
suspenduser:
|
||||
bio: bio
|
||||
bio: "bio"
|
||||
cancel: "Cancelar"
|
||||
days: "{0} días"
|
||||
description_0: "¿Quiere suspender temporalmente a este usuario por su {0}? Hacerlo\
|
||||
\ va a ocultar temporalmente todos sus comentarios hasta que edite su {0}."
|
||||
description_1: "Suspender a este usuario va a deshabilitar temporalmente su cuenta\
|
||||
\ y ocultar todos sus comentarios en el sitio."
|
||||
description_notify: "Suspender a este usuario va a deshabilitar temporalmente\
|
||||
\ su cuenta y ocultar todos sus comentarios en el sitio."
|
||||
description_reject: "¿Quiere suspender temporalmente a este usuario por su {0}?\
|
||||
\ Hacerlo va a ocultar temporalmente sus comentarios hasta que edite su {0}."
|
||||
description_suspend: "Esta suspendiendo a {0}. Este comentario va a ir a la fila\
|
||||
\ de Rechazados, y a {0} no se le va a permitir gustar, marcar, responder o\
|
||||
\ publicar hasta que el tiempo de suspendido halla concluido."
|
||||
email: "Otro miembro de la comunidad recientemente marco su nombre de usuario\
|
||||
\ para revisarlo. Por su contenido su nombre de usuario ha sido rechazado. Esto\
|
||||
\ significa que no puede comentar o marcar contenido hasta que edite su nombre\
|
||||
\ de usuario. Envíenos un correo si tiene preguntas o comentarios."
|
||||
description_0: "¿Quiere suspender temporalmente a este usuario por su {0}? Hacerlo va a ocultar temporalmente todos sus comentarios hasta que edite su {0}."
|
||||
description_1: "Suspender a este usuario va a deshabilitar temporalmente su cuenta y ocultar todos sus comentarios en el sitio."
|
||||
description_notify: "Suspender a este usuario va a deshabilitar temporalmente su cuenta y ocultar todos sus comentarios en el sitio."
|
||||
description_reject: "¿Quiere suspender temporalmente a este usuario por su {0}? Hacerlo va a ocultar temporalmente sus comentarios hasta que edite su {0}."
|
||||
description_suspend: "Esta suspendiendo a {0}. Este comentario va a ir a la fila de Rechazados, y a {0} no se le va a permitir gustar, marcar, responder o publicar hasta que el tiempo de suspendido halla concluido."
|
||||
email: "Otro miembro de la comunidad recientemente marco su nombre de usuario para revisarlo. Por su contenido su nombre de usuario ha sido rechazado. Esto significa que no puede comentar o marcar contenido hasta que edite su nombre de usuario. Envíenos un correo si tiene preguntas o comentarios."
|
||||
email_subject: "Su cuenta ha sido suspendida"
|
||||
email_message_reject: "Otro miembro de la comunidad hace poco ha marcado su nombre\
|
||||
\ de usuario para revisar. Y este ha sido rechazado por su contenido. Esto significa\
|
||||
\ que no puede comentar, gustar o marcar contenido hasta que edite su nombre\
|
||||
\ de usuario. Envíenos un correo si tiene alguna preocupación o pregunta."
|
||||
email_message_suspend: "Querida {0},\n\nDe acuerdo a la guía comunitaria de {1},\
|
||||
\ su cuenta ha sido temporalmente suspendida. Durante este tiempo, no podrá\
|
||||
\ comentar, marcar o involucrarse con otros comentaristas. Por favor unirse\
|
||||
\ a la conversación {2}."
|
||||
email_message_reject: "Otro miembro de la comunidad hace poco ha marcado su nombre de usuario para revisar. Y este ha sido rechazado por su contenido. Esto significa que no puede comentar, gustar o marcar contenido hasta que edite su nombre de usuario. Envíenos un correo si tiene alguna preocupación o pregunta."
|
||||
email_message_suspend: "Querida {0}, De acuerdo a la guía comunitaria de {1}, su cuenta ha sido temporalmente suspendida. Durante este tiempo, no podrá comentar, marcar o involucrarse con otros comentaristas. Por favor unirse a la conversación {2}."
|
||||
error_email_message_empty: "Debe especificar un mensaje de correo."
|
||||
hours: "{0} horas"
|
||||
no_cancel: "No cancelar"
|
||||
notify_suspend_until: "Usuario {0} ha sido temporalmente suspendido. Esta suspension\
|
||||
\ va a terminar automáticamente en {1}."
|
||||
notify_suspend_until: "Usuario {0} ha sido temporalmente suspendido. Esta suspension va a terminar automáticamente en {1}."
|
||||
one_hour: "1 hora"
|
||||
send: Enviar
|
||||
send: "Enviar"
|
||||
select_duration: "Seleccionar la duración de la suspensión"
|
||||
suspend_user: "Suspender Usuario"
|
||||
title: "Suspender un usuario"
|
||||
@@ -383,8 +379,7 @@ es:
|
||||
username: nombre de usuario
|
||||
write_message: "Escribir un mensaje"
|
||||
yes_suspend: "Si, suspender"
|
||||
thank_you: "Valoramos tanto su seguridad en este espacio como sus comentarios. Un\
|
||||
\ o una moderadora va a leer su reporte."
|
||||
thank_you: "Valoramos tanto su seguridad en este espacio como sus comentarios. Un o una moderadora va a leer su reporte."
|
||||
unset_best: "Des-etiquetar como el mejor"
|
||||
user:
|
||||
bio_flags: "reportes para este bio"
|
||||
@@ -395,29 +390,24 @@ es:
|
||||
username_offensive: "Este nombre de usuario es ofensivo"
|
||||
view_conversation: "Ver Conversación"
|
||||
install:
|
||||
initial:
|
||||
description: "Vamos a crear su comunidad Talk en unos pocos pasos."
|
||||
submit: "Empecemos"
|
||||
add_organization:
|
||||
description: "Por favor díganos el nombre de su organización. Esta aparecerá\
|
||||
\ en correos cuando se inviten a nuevos miembros del equipo."
|
||||
label: "Nombre de la Organización"
|
||||
save: "Guardar"
|
||||
create:
|
||||
email: "Dirección de correo"
|
||||
username: "Nombre de Usuario"
|
||||
password: "Contraseña"
|
||||
confirm_password: "Confirmar Contraseña"
|
||||
save: "Guardar"
|
||||
permitted_domains:
|
||||
title: "Dominios permitidos"
|
||||
description: "Ingresar los dominios en donde estará Talk, por ejemplo sus ambientes\
|
||||
\ locales, de staging y producción (ej. localhost:3000, staging.domain.com,\
|
||||
\ domain.com)."
|
||||
submit: "Terminar la Instalación"
|
||||
final:
|
||||
description: "¡Gracias por instalar Talk! Enviamos un correo para verificar\
|
||||
\ su dirección de correo. Mientras esta terminando de configurar su cuenta,\
|
||||
\ ya puede comenzar a involucrarse con sus lectores."
|
||||
launch: "Iniciar Talk"
|
||||
close: "Cerrar este instalador"
|
||||
INITIAL:
|
||||
DESCRIPTION: "Vamos a crear su comunidad Talk en unos pocos pasos."
|
||||
SUBMIT: "Empecemos"
|
||||
ADD_ORGANIZATION:
|
||||
DESCRIPTION: "Por favor díganos el nombre de su organización. Esta aparecerá en correos cuando se inviten a nuevos miembros del equipo."
|
||||
LABEL: "Nombre de la Organización"
|
||||
SAVE: "Guardar"
|
||||
CREATE:
|
||||
EMAIL: "Dirección de correo"
|
||||
USERNAME: "Nombre de Usuario"
|
||||
PASSWORD: "Contraseña"
|
||||
CONFIRM_PASSWORD: "Confirmar Contraseña"
|
||||
SAVE: "Guardar"
|
||||
PERMITTED_DOMAINS:
|
||||
TITLE: "Dominios permitidos"
|
||||
DESCRIPTION: "Ingresar los dominios en donde estará Talk, por ejemplo sus ambientes locales, de staging y producción (ej. localhost:3000, staging.domain.com, domain.com)."
|
||||
SUBMIT: "Terminar la Instalación"
|
||||
FINAL:
|
||||
DESCRIPTION: "¡Gracias por instalar Talk! Enviamos un correo para verificar su dirección de correo. Mientras esta terminando de configurar su cuenta, ya puede comenzar a involucrarse con sus lectores."
|
||||
LAUNCH: "Iniciar Talk"
|
||||
CLOSE: "Cerrar este instalador"
|
||||
|
||||
+4
-1
@@ -104,7 +104,10 @@ const CommentSchema = new Schema({
|
||||
timestamps: {
|
||||
createdAt: 'created_at',
|
||||
updatedAt: 'updated_at'
|
||||
}
|
||||
},
|
||||
toJSON: {
|
||||
virtuals: true,
|
||||
},
|
||||
});
|
||||
|
||||
CommentSchema.virtual('edited').get(function() {
|
||||
|
||||
+5
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "talk",
|
||||
"version": "1.8.0",
|
||||
"version": "1.9.1",
|
||||
"description": "A better commenting experience from Mozilla, The New York Times, and the Washington Post. https://coralproject.net",
|
||||
"main": "app.js",
|
||||
"scripts": {
|
||||
@@ -92,11 +92,11 @@
|
||||
"minimist": "^1.2.0",
|
||||
"mongoose": "^4.9.8",
|
||||
"morgan": "^1.8.1",
|
||||
"ms": "^2.0.0",
|
||||
"natural": "^0.5.0",
|
||||
"node-emoji": "^1.5.1",
|
||||
"node-fetch": "^1.6.3",
|
||||
"nodemailer": "^2.6.4",
|
||||
"parse-duration": "^0.1.1",
|
||||
"passport": "^0.3.2",
|
||||
"passport-jwt": "^2.2.1",
|
||||
"passport-local": "^1.0.0",
|
||||
@@ -104,6 +104,7 @@
|
||||
"react-apollo": "^1.1.0",
|
||||
"react-recaptcha": "^2.2.6",
|
||||
"react-toastify": "^1.5.0",
|
||||
"react-transition-group": "^1.1.3",
|
||||
"recompose": "^0.23.1",
|
||||
"redis": "^2.7.1",
|
||||
"resolve": "^1.3.2",
|
||||
@@ -112,7 +113,8 @@
|
||||
"subscriptions-transport-ws": "^0.5.5-alpha.0",
|
||||
"timekeeper": "^1.0.0",
|
||||
"uuid": "^3.0.1",
|
||||
"yaml-loader": "^0.4.0"
|
||||
"yaml-loader": "^0.4.0",
|
||||
"yamljs": "^0.2.10"
|
||||
},
|
||||
"devDependencies": {
|
||||
"apollo-client": "^1.0.4",
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"env": {
|
||||
"browser": true,
|
||||
"es6": true,
|
||||
"mocha": true
|
||||
},
|
||||
"parserOptions": {
|
||||
"sourceType": "module",
|
||||
"ecmaFeatures": {
|
||||
"experimentalObjectRestSpread": true,
|
||||
"jsx": true
|
||||
}
|
||||
},
|
||||
"parser": "babel-eslint",
|
||||
"plugins": [
|
||||
"react"
|
||||
],
|
||||
"rules": {
|
||||
"react/jsx-uses-react": "error",
|
||||
"react/jsx-uses-vars": "error",
|
||||
"no-console": ["warn", { "allow": ["warn", "error"] }]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export {addTag, removeTag} from 'coral-plugin-commentbox/actions';
|
||||
export {addCommentClassName, removeCommentClassName} from 'coral-embed-stream/src/actions/stream';
|
||||
@@ -0,0 +1,2 @@
|
||||
export const commentBoxTagsSelector = (state) => state.commentBox.tags;
|
||||
export const commentClassNamesSelector = (state) => state.stream.commentClassNames;
|
||||
@@ -0,0 +1,2 @@
|
||||
export {Slot} from 'coral-framework/components';
|
||||
export {Icon} from 'coral-ui';
|
||||
@@ -0,0 +1 @@
|
||||
export {withReaction} from 'coral-framework/hocs';
|
||||
@@ -0,0 +1 @@
|
||||
export {t} from 'coral-framework/services/i18n';
|
||||
@@ -3,11 +3,14 @@
|
||||
"coral-plugin-respect",
|
||||
"coral-plugin-like",
|
||||
"coral-plugin-facebook-auth",
|
||||
"coral-plugin-auth"
|
||||
"coral-plugin-auth",
|
||||
"coral-plugin-offtopic"
|
||||
],
|
||||
"client": [
|
||||
"coral-plugin-respect",
|
||||
"coral-plugin-like",
|
||||
"coral-plugin-auth"
|
||||
"coral-plugin-auth",
|
||||
"coral-plugin-offtopic",
|
||||
"coral-plugin-viewing-options"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -3,12 +3,13 @@ import {Button} from 'coral-ui';
|
||||
import {connect} from 'react-redux';
|
||||
import {bindActionCreators} from 'redux';
|
||||
import {showSignInDialog} from 'coral-framework/actions/auth';
|
||||
import t from 'coral-framework/services/i18n';
|
||||
|
||||
const SignInButton = ({loggedIn, showSignInDialog}) => (
|
||||
<div>
|
||||
{!loggedIn
|
||||
? <Button id="coralSignInButton" onClick={showSignInDialog} full>
|
||||
Sign in to comment
|
||||
{t('sign_in.sign_in_to_comment')}
|
||||
</Button>
|
||||
: null}
|
||||
</div>
|
||||
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
facebookCallback,
|
||||
invalidForm,
|
||||
validForm,
|
||||
checkLogin
|
||||
} from 'coral-framework/actions/auth';
|
||||
|
||||
class SignInContainer extends React.Component {
|
||||
@@ -38,10 +37,6 @@ class SignInContainer extends React.Component {
|
||||
};
|
||||
}
|
||||
|
||||
componentWillMount() {
|
||||
this.props.checkLogin();
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
window.addEventListener('storage', this.handleAuth);
|
||||
|
||||
@@ -187,7 +182,6 @@ const mapStateToProps = (state) => ({
|
||||
const mapDispatchToProps = (dispatch) =>
|
||||
bindActionCreators(
|
||||
{
|
||||
checkLogin,
|
||||
facebookCallback,
|
||||
fetchSignUp,
|
||||
fetchSignIn,
|
||||
|
||||
@@ -9,10 +9,10 @@ const UserBox = ({loggedIn, user, logout, onShowProfile}) => (
|
||||
<div>
|
||||
{
|
||||
loggedIn ? (
|
||||
<div className={styles.userBox}>
|
||||
{t('sign_in.logged_in_as')}
|
||||
<div className={`${styles.userBox} talk-stream-auth-userbox`}>
|
||||
<span className={styles.userBoxLoggedIn}>{t('sign_in.logged_in_as')}</span>
|
||||
<a onClick={onShowProfile}>{user.username}</a>. {t('sign_in.not_you')}
|
||||
<a className={styles.logout} onClick={() => logout()}>
|
||||
<a className={`${styles.logout} talk-stream-userbox-logout`} onClick={() => logout()}>
|
||||
{t('sign_in.logout')}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -70,6 +70,10 @@ input.error{
|
||||
letter-spacing: 0.1px;
|
||||
}
|
||||
|
||||
.userBoxLoggedIn {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.userBox a {
|
||||
color: black;
|
||||
font-weight: bold;
|
||||
|
||||
@@ -5,10 +5,10 @@ en:
|
||||
verify_email: "Thank you for creating an account! We sent an email to the address you provided to verify your account."
|
||||
verify_email2: "You must verify your account before engaging with the community."
|
||||
not_you: "Not you?"
|
||||
logged_in_as: "Logged in as"
|
||||
logged_in_as: "Signed in as"
|
||||
facebook_sign_in: "Sign in with Facebook"
|
||||
facebook_sign_up: "Sign up with Facebook"
|
||||
logout: "Logout"
|
||||
logout: "Sign out"
|
||||
sign_in: "Sign in"
|
||||
sign_in_to_join: "Sign in to join the conversation"
|
||||
or: "Or"
|
||||
|
||||
@@ -2,7 +2,7 @@ import React from 'react';
|
||||
import {Icon} from 'coral-ui';
|
||||
import styles from './styles.css';
|
||||
import t from 'coral-framework/services/i18n';
|
||||
import {withReaction} from 'coral-plugin-api';
|
||||
import {withReaction} from 'plugin-api/beta/client/hocs';
|
||||
|
||||
class LoveButton extends React.Component {
|
||||
handleClick = () => {
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import {OFFTOPIC_TOGGLE_CHECKBOX} from './constants';
|
||||
|
||||
export const toggleCheckbox = () => ({
|
||||
type: OFFTOPIC_TOGGLE_CHECKBOX
|
||||
});
|
||||
@@ -1,39 +1,45 @@
|
||||
import React from 'react';
|
||||
import {connect} from 'react-redux';
|
||||
import {bindActionCreators} from 'redux';
|
||||
import {addTag, removeTag} from 'coral-plugin-commentbox/actions';
|
||||
import styles from './styles.css';
|
||||
|
||||
import t from 'coral-framework/services/i18n';
|
||||
import {t} from 'plugin-api/beta/client/services';
|
||||
|
||||
class OffTopicCheckbox extends React.Component {
|
||||
export default class OffTopicCheckbox extends React.Component {
|
||||
|
||||
label = 'OFF_TOPIC';
|
||||
|
||||
handleChange = (e) => {
|
||||
if (e.target.checked) {
|
||||
this.props.addTag(this.label);
|
||||
} else {
|
||||
const idx = this.props.commentBox.tags.indexOf(this.label);
|
||||
componentDidMount() {
|
||||
this.clearTagsHook = this.props.registerHook('postSubmit', () => {
|
||||
const idx = this.props.tags.indexOf(this.label);
|
||||
this.props.removeTag(idx);
|
||||
});
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.props.unregisterHook(this.clearTagsHook);
|
||||
}
|
||||
|
||||
handleChange = (e) => {
|
||||
const {addTag, removeTag} = this.props;
|
||||
if (e.target.checked) {
|
||||
addTag(this.label);
|
||||
} else {
|
||||
const idx = this.props.tags.indexOf(this.label);
|
||||
removeTag(idx);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className={styles.offTopic}>
|
||||
<label className={styles.offTopicLabel}>
|
||||
<input type="checkbox" onChange={this.handleChange}/>
|
||||
{t('off_topic')}
|
||||
</label>
|
||||
{
|
||||
!this.props.isReply ? (
|
||||
<label className={styles.offTopicLabel}>
|
||||
<input type="checkbox" onChange={this.handleChange}/>
|
||||
{t('off_topic')}
|
||||
</label>
|
||||
) : null
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const mapStateToProps = ({commentBox}) => ({commentBox});
|
||||
|
||||
const mapDispatchToProps = (dispatch) =>
|
||||
bindActionCreators({addTag, removeTag}, dispatch);
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(OffTopicCheckbox);
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import React from 'react';
|
||||
import styles from './styles.css';
|
||||
|
||||
export default class OffTopicFilter extends React.Component {
|
||||
|
||||
tag = 'OFF_TOPIC';
|
||||
className = 'coral-plugin-off-topic-comment';
|
||||
cn = {[this.className] : {tags: [this.tag]}};
|
||||
|
||||
handleChange = (e) => {
|
||||
if (e.target.checked) {
|
||||
this.props.addCommentClassName(this.cn);
|
||||
this.props.toggleCheckbox();
|
||||
} else {
|
||||
const idx = this.props.commentClassNames.findIndex((i) => i[this.className]);
|
||||
this.props.removeCommentClassName(idx);
|
||||
this.props.toggleCheckbox();
|
||||
}
|
||||
this.props.closeViewingOptions();
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className={styles.viewingOption}>
|
||||
<label>
|
||||
<input type="checkbox" onChange={this.handleChange} checked={this.props.checked} />
|
||||
Hide Off-Topic Comments
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import styles from './styles.css';
|
||||
|
||||
import t from 'coral-framework/services/i18n';
|
||||
import {t} from 'plugin-api/beta/client/services';
|
||||
|
||||
const isOffTopic = (tags) => {
|
||||
return !!tags.filter((tag) => tag.name === 'OFF_TOPIC').length;
|
||||
@@ -10,7 +10,7 @@ const isOffTopic = (tags) => {
|
||||
export default (props) => (
|
||||
<span>
|
||||
{
|
||||
isOffTopic(props.comment.tags) ? (
|
||||
isOffTopic(props.comment.tags) && props.depth === 0 ? (
|
||||
<span className={styles.tag}>
|
||||
{t('off_topic')}
|
||||
</span>
|
||||
|
||||
@@ -8,11 +8,20 @@
|
||||
}
|
||||
|
||||
.tag {
|
||||
background: rgba(245, 188, 168, 0.6);
|
||||
background: #3D73D5;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
color: white;
|
||||
display: inline-block;
|
||||
margin: 0px 5px;
|
||||
padding: 5px 5px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.viewingOption {
|
||||
padding: 5px 0;
|
||||
}
|
||||
|
||||
:global(.coral-plugin-off-topic-comment) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export const OFFTOPIC_TOGGLE_CHECKBOX = 'OFFTOPIC_TOGGLE_CHECKBOX';
|
||||
@@ -0,0 +1,14 @@
|
||||
import {connect} from 'react-redux';
|
||||
import {bindActionCreators} from 'redux';
|
||||
import {addTag, removeTag} from 'plugin-api/alpha/client/actions';
|
||||
import {commentBoxTagsSelector} from 'plugin-api/alpha/client/selectors';
|
||||
import OffTopicCheckbox from '../components/OffTopicCheckbox';
|
||||
|
||||
const mapStateToProps = (state) => ({
|
||||
tags: commentBoxTagsSelector(state)
|
||||
});
|
||||
|
||||
const mapDispatchToProps = (dispatch) =>
|
||||
bindActionCreators({addTag, removeTag}, dispatch);
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(OffTopicCheckbox);
|
||||
@@ -0,0 +1,30 @@
|
||||
import {connect} from 'react-redux';
|
||||
import {bindActionCreators} from 'redux';
|
||||
import {toggleCheckbox} from '../actions';
|
||||
import {commentClassNamesSelector} from 'plugin-api/alpha/client/selectors';
|
||||
import OffTopicFilter from '../components/OffTopicFilter';
|
||||
import {
|
||||
closeViewingOptions
|
||||
} from 'plugins/coral-plugin-viewing-options/client/actions';
|
||||
import {
|
||||
addCommentClassName,
|
||||
removeCommentClassName
|
||||
} from 'plugin-api/alpha/client/actions';
|
||||
|
||||
const mapStateToProps = (state) => ({
|
||||
commentClassNames: commentClassNamesSelector(state),
|
||||
checked: state.coralPluginOfftopic.checked
|
||||
});
|
||||
|
||||
const mapDispatchToProps = (dispatch) =>
|
||||
bindActionCreators(
|
||||
{
|
||||
toggleCheckbox,
|
||||
closeViewingOptions,
|
||||
addCommentClassName,
|
||||
removeCommentClassName
|
||||
},
|
||||
dispatch
|
||||
);
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(OffTopicFilter);
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user