mirror of
https://github.com/wassname/talk.git
synced 2026-08-08 11:28:12 +08:00
Merge branch 'master' into plugins-debug
This commit is contained in:
@@ -2,33 +2,4 @@
|
||||
dist
|
||||
docs
|
||||
node_modules
|
||||
plugins/*
|
||||
public
|
||||
|
||||
!plugins/talk-plugin-akismet
|
||||
!plugins/talk-plugin-auth
|
||||
!plugins/talk-plugin-author-menu
|
||||
!plugins/talk-plugin-comment-content
|
||||
!plugins/talk-plugin-deep-reply-count
|
||||
!plugins/talk-plugin-facebook-auth
|
||||
!plugins/talk-plugin-featured-comments
|
||||
!plugins/talk-plugin-flag-details
|
||||
!plugins/talk-plugin-ignore-user
|
||||
!plugins/talk-plugin-like
|
||||
!plugins/talk-plugin-love
|
||||
!plugins/talk-plugin-member-since
|
||||
!plugins/talk-plugin-mod
|
||||
!plugins/talk-plugin-moderation-actions
|
||||
!plugins/talk-plugin-offtopic
|
||||
!plugins/talk-plugin-permalink
|
||||
!plugins/talk-plugin-remember-sort
|
||||
!plugins/talk-plugin-respect
|
||||
!plugins/talk-plugin-sort-most-liked
|
||||
!plugins/talk-plugin-sort-most-loved
|
||||
!plugins/talk-plugin-sort-most-replied
|
||||
!plugins/talk-plugin-sort-most-respected
|
||||
!plugins/talk-plugin-sort-newest
|
||||
!plugins/talk-plugin-sort-oldest
|
||||
!plugins/talk-plugin-subscriber
|
||||
!plugins/talk-plugin-toxic-comments
|
||||
!plugins/talk-plugin-viewing-options
|
||||
+25
-21
@@ -23,35 +23,39 @@ browserstack.err
|
||||
|
||||
plugins.json
|
||||
plugins/*
|
||||
|
||||
!plugins/talk-plugin-akismet
|
||||
!plugins/talk-plugin-facebook-auth
|
||||
!plugins/talk-plugin-auth
|
||||
!plugins/talk-plugin-respect
|
||||
!plugins/talk-plugin-offtopic
|
||||
!plugins/talk-plugin-like
|
||||
!plugins/talk-plugin-mod
|
||||
!plugins/talk-plugin-love
|
||||
!plugins/talk-plugin-viewing-options
|
||||
!plugins/talk-plugin-author-menu
|
||||
!plugins/talk-plugin-comment-content
|
||||
!plugins/talk-plugin-permalink
|
||||
!plugins/talk-plugin-deep-reply-count
|
||||
!plugins/talk-plugin-facebook-auth
|
||||
!plugins/talk-plugin-featured-comments
|
||||
!plugins/talk-plugin-toxic-comments
|
||||
!plugins/talk-plugin-sort-newest
|
||||
!plugins/talk-plugin-sort-oldest
|
||||
!plugins/talk-plugin-sort-most-replied
|
||||
!plugins/talk-plugin-flag-details
|
||||
!plugins/talk-plugin-google-auth
|
||||
!plugins/talk-plugin-ignore-user
|
||||
!plugins/talk-plugin-like
|
||||
!plugins/talk-plugin-love
|
||||
!plugins/talk-plugin-member-since
|
||||
!plugins/talk-plugin-mod
|
||||
!plugins/talk-plugin-moderation-actions
|
||||
!plugins/talk-plugin-notifications
|
||||
!plugins/talk-plugin-notifications-category-reply
|
||||
!plugins/talk-plugin-offtopic
|
||||
!plugins/talk-plugin-permalink
|
||||
!plugins/talk-plugin-profile-settings
|
||||
!plugins/talk-plugin-remember-sort
|
||||
!plugins/talk-plugin-respect
|
||||
!plugins/talk-plugin-slack-notifications
|
||||
!plugins/talk-plugin-sort-most-liked
|
||||
!plugins/talk-plugin-sort-most-loved
|
||||
!plugins/talk-plugin-sort-most-replied
|
||||
!plugins/talk-plugin-sort-most-respected
|
||||
!plugins/talk-plugin-author-menu
|
||||
!plugins/talk-plugin-member-since
|
||||
!plugins/talk-plugin-ignore-user
|
||||
!plugins/talk-plugin-moderation-actions
|
||||
!plugins/talk-plugin-toxic-comments
|
||||
!plugins/talk-plugin-remember-sort
|
||||
!plugins/talk-plugin-deep-reply-count
|
||||
!plugins/talk-plugin-sort-newest
|
||||
!plugins/talk-plugin-sort-oldest
|
||||
!plugins/talk-plugin-subscriber
|
||||
!plugins/talk-plugin-flag-details
|
||||
!plugins/talk-plugin-slack-notifications
|
||||
!plugins/talk-plugin-toxic-comments
|
||||
!plugins/talk-plugin-viewing-options
|
||||
|
||||
**/node_modules/*
|
||||
yarn-error.log
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"linters": {
|
||||
"*.js": [
|
||||
"git-exec-and-restage eslint --fix --"
|
||||
],
|
||||
"bin/cli*": [
|
||||
"git-exec-and-restage eslint --fix --"
|
||||
],
|
||||
"*.yml": [
|
||||
"yamllint"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"exceptions": [
|
||||
"https://nodesecurity.io/advisories/531",
|
||||
"https://nodesecurity.io/advisories/532"
|
||||
"https://nodesecurity.io/advisories/532",
|
||||
"https://nodesecurity.io/advisories/566"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ ENV NODE_ENV production
|
||||
# Install app dependencies and build static assets.
|
||||
RUN yarn global add node-gyp && \
|
||||
yarn install --frozen-lockfile && \
|
||||
cli plugins reconcile && \
|
||||
yarn build && \
|
||||
yarn cache clean
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
const express = require('express');
|
||||
const morgan = require('morgan');
|
||||
const path = require('path');
|
||||
const uuid = require('uuid');
|
||||
const merge = require('lodash/merge');
|
||||
const helmet = require('helmet');
|
||||
const plugins = require('./services/plugins');
|
||||
@@ -13,13 +12,6 @@ const { ENABLE_TRACING, APOLLO_ENGINE_KEY, PORT } = require('./config');
|
||||
|
||||
const app = express();
|
||||
|
||||
// Request Identity Middleware
|
||||
app.use((req, res, next) => {
|
||||
req.id = uuid.v4();
|
||||
|
||||
next();
|
||||
});
|
||||
|
||||
//==============================================================================
|
||||
// PLUGIN PRE APPLICATION MIDDLEWARE
|
||||
//==============================================================================
|
||||
|
||||
+3
-7
@@ -6,8 +6,7 @@
|
||||
|
||||
const util = require('./util');
|
||||
const program = require('commander');
|
||||
const scraper = require('../services/scraper');
|
||||
const mailer = require('../services/mailer');
|
||||
const jobs = require('../jobs');
|
||||
const mongoose = require('../services/mongoose');
|
||||
const kue = require('../services/kue');
|
||||
|
||||
@@ -21,11 +20,8 @@ function processJobs() {
|
||||
// started.
|
||||
util.onshutdown([() => kue.Task.shutdown()]);
|
||||
|
||||
// Start the scraper processor.
|
||||
scraper.process();
|
||||
|
||||
// Start the mail processor.
|
||||
mailer.process();
|
||||
// Start the jobs processor.
|
||||
jobs.process();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+29
-97
@@ -135,18 +135,11 @@ function reconcilePackages({ quiet = false, upgradeRemote = false }) {
|
||||
return { local, fetchable, upgradable };
|
||||
}
|
||||
|
||||
async function reconcileRemotePlugins({ skipLocal, dryRun, upgradeRemote }) {
|
||||
console.log(
|
||||
`\n[${skipLocal ? '1/2' : '2/3'}] ${emoji.get(
|
||||
'mag'
|
||||
)} Reconciling plugins...`.yellow
|
||||
);
|
||||
async function reconcileRemotePlugins({ dryRun, upgradeRemote }) {
|
||||
console.log(`\n['1/2'] ${emoji.get('mag')} Reconciling plugins...`.yellow);
|
||||
const { fetchable, upgradable } = reconcilePackages({ upgradeRemote });
|
||||
|
||||
console.log(
|
||||
`[${skipLocal ? '2/2' : '3/3'}] ${emoji.get('truck')} Fetching plugins...\n`
|
||||
.yellow
|
||||
);
|
||||
console.log(`['2/2'] ${emoji.get('truck')} Fetching plugins...\n`.yellow);
|
||||
|
||||
if (fetchable.length > 0) {
|
||||
console.log(
|
||||
@@ -206,98 +199,41 @@ async function reconcileRemotePlugins({ skipLocal, dryRun, upgradeRemote }) {
|
||||
return { upgradable, fetchable };
|
||||
}
|
||||
|
||||
async function reconcileLocalPlugins({ skipRemote, dryRun }) {
|
||||
console.log(
|
||||
`\n[${skipRemote ? '1/1' : '1/3'}] ${emoji.get(
|
||||
'pick'
|
||||
)} Installing local plugin dependencies...\n`.yellow
|
||||
);
|
||||
const { local } = reconcilePackages({ quiet: true });
|
||||
|
||||
for (let i in local) {
|
||||
let { name } = local[i];
|
||||
|
||||
if (!fs.existsSync(path.join(dir, 'plugins', name, 'package.json'))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let wd = path.join(dir, 'plugins', name);
|
||||
|
||||
console.log(`$ cd ${wd.cyan} && yarn`);
|
||||
|
||||
if (!dryRun) {
|
||||
let args = [];
|
||||
|
||||
let output = spawn.sync('yarn', args, {
|
||||
stdio: ['ignore', 'pipe', 'inherit'],
|
||||
cwd: wd,
|
||||
});
|
||||
|
||||
if (output.status) {
|
||||
throw new Error(
|
||||
'Could not install local plugin dependencies, errors occurred during install'
|
||||
);
|
||||
}
|
||||
|
||||
console.log(output.stdout.toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// This traverses the local plugins and installs any dependencies listed there,
|
||||
// this only is really needed for plugins that are installed via docker because
|
||||
// core plugins will have their dependencies already included in core.
|
||||
async function reconcilePluginDeps({
|
||||
skipLocal,
|
||||
skipRemote,
|
||||
dryRun,
|
||||
upgradeRemote,
|
||||
}) {
|
||||
async function reconcilePluginDeps({ dryRun, upgradeRemote }) {
|
||||
try {
|
||||
let startTime = new Date();
|
||||
|
||||
// We don't need to do anything if we skip everything....
|
||||
if (skipLocal && skipRemote) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Traverse local plugins and install dependencies if enabled.
|
||||
if (!skipLocal) {
|
||||
await reconcileLocalPlugins({ skipRemote, dryRun });
|
||||
}
|
||||
|
||||
// Locate any external plugins and install them.
|
||||
if (!skipRemote) {
|
||||
const results = await reconcileRemotePlugins({
|
||||
skipLocal,
|
||||
skipRemote,
|
||||
dryRun,
|
||||
upgradeRemote,
|
||||
});
|
||||
const results = await reconcileRemotePlugins({
|
||||
dryRun,
|
||||
upgradeRemote,
|
||||
});
|
||||
|
||||
let status;
|
||||
if (dryRun) {
|
||||
status = '[dry-run] success'.green;
|
||||
} else {
|
||||
status = 'success'.green;
|
||||
}
|
||||
|
||||
let message;
|
||||
if (results.upgradable.length === 0 && results.fetchable.length === 0) {
|
||||
message = 'Already up-to-date.';
|
||||
} else if (results.upgradable.length === 0) {
|
||||
message = `Fetched ${results.fetchable.length} new plugins.`;
|
||||
} else if (results.fetchable.length === 0) {
|
||||
message = `Upgraded ${results.upgradable.length} new plugins.`;
|
||||
} else {
|
||||
message = `Fetched ${results.fetchable.length} new plugins, upgraded ${
|
||||
results.upgradable.length
|
||||
} plugins.`;
|
||||
}
|
||||
|
||||
console.log(`\n${status} ${message}`);
|
||||
let status;
|
||||
if (dryRun) {
|
||||
status = '[dry-run] success'.green;
|
||||
} else {
|
||||
status = 'success'.green;
|
||||
}
|
||||
|
||||
let message;
|
||||
if (results.upgradable.length === 0 && results.fetchable.length === 0) {
|
||||
message = 'Already up-to-date.';
|
||||
} else if (results.upgradable.length === 0) {
|
||||
message = `Fetched ${results.fetchable.length} new plugins.`;
|
||||
} else if (results.fetchable.length === 0) {
|
||||
message = `Upgraded ${results.upgradable.length} new plugins.`;
|
||||
} else {
|
||||
message = `Fetched ${results.fetchable.length} new plugins, upgraded ${
|
||||
results.upgradable.length
|
||||
} plugins.`;
|
||||
}
|
||||
|
||||
console.log(`\n${status} ${message}`);
|
||||
|
||||
let endTime = new Date();
|
||||
|
||||
let totalTime = ((endTime.getTime() - startTime.getTime()) / 1000).toFixed(
|
||||
@@ -440,16 +376,12 @@ program
|
||||
|
||||
program
|
||||
.command('reconcile')
|
||||
.description(
|
||||
'reconciles local plugin dependencies and downloads external plugins'
|
||||
)
|
||||
.description('reconciles dependencies by downloading external plugins')
|
||||
.option('-u, --upgrade-remote', 'upgrades remote dependencies')
|
||||
.option(
|
||||
'-d, --dry-run',
|
||||
'does not actually change anything on the filesystem acts only as a simulation'
|
||||
)
|
||||
.option('--skip-local', 'skips the local dependancy reconciliation')
|
||||
.option('--skip-remote', 'skips the remote plugin reconciliation')
|
||||
.action(reconcilePluginDeps);
|
||||
|
||||
program.parse(process.argv);
|
||||
|
||||
@@ -15,6 +15,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
.username {
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.headerRowItem {
|
||||
color: #595959;
|
||||
font-weight: bold;
|
||||
|
||||
@@ -5,7 +5,10 @@ import styles from './AccountHistory.css';
|
||||
import cn from 'classnames';
|
||||
import flatten from 'lodash/flatten';
|
||||
import orderBy from 'lodash/orderBy';
|
||||
import has from 'lodash/has';
|
||||
import moment from 'moment';
|
||||
import t from 'coral-framework/services/i18n';
|
||||
import { Icon } from 'coral-ui';
|
||||
|
||||
const buildUserHistory = (userState = {}) => {
|
||||
return orderBy(
|
||||
@@ -29,31 +32,39 @@ const readableDuration = (startDate, endDate) => {
|
||||
const durAsHours = dur.asHours().toFixed(0);
|
||||
|
||||
return durAsHours > 23
|
||||
? `${durAsDays} ${durAsDays > 1 ? 'days' : 'day'}`
|
||||
: `${durAsHours} ${durAsHours > 1 ? 'hours' : 'hour'}`;
|
||||
? durAsDays > 1
|
||||
? t('suspenduser.days', durAsDays)
|
||||
: t('suspenduser.day', durAsDays)
|
||||
: durAsHours > 1
|
||||
? t('suspenduser.hours', durAsHours)
|
||||
: t('suspenduser.hour', durAsHours);
|
||||
};
|
||||
|
||||
const buildActionResponse = (typename, created_at, until, status) => {
|
||||
switch (typename) {
|
||||
case 'UsernameStatusHistory':
|
||||
return `Username ${status}`;
|
||||
return t('account_history.username_status', status);
|
||||
case 'BannedStatusHistory':
|
||||
return status ? 'User banned' : 'Ban removed';
|
||||
return status
|
||||
? t('account_history.user_banned')
|
||||
: t('account_history.ban_removed');
|
||||
case 'SuspensionStatusHistory':
|
||||
return until
|
||||
? `Suspended, ${readableDuration(created_at, until)}`
|
||||
: 'Suspension removed';
|
||||
? t('account_history.suspended', readableDuration(created_at, until))
|
||||
: t('account_history.suspension_removed');
|
||||
default:
|
||||
return '-';
|
||||
}
|
||||
};
|
||||
|
||||
const getModerationValue = (userId, assignedBy = {}) => {
|
||||
if (assignedBy && userId !== assignedBy.id) {
|
||||
return assignedBy.username;
|
||||
}
|
||||
return 'SYSTEM';
|
||||
};
|
||||
const getModerationValue = assignedBy =>
|
||||
has(assignedBy, 'username') ? (
|
||||
assignedBy.username
|
||||
) : (
|
||||
<span>
|
||||
<Icon name="computer" /> {t('account_history.system')}
|
||||
</span>
|
||||
);
|
||||
|
||||
class AccountHistory extends React.Component {
|
||||
render() {
|
||||
@@ -68,9 +79,15 @@ class AccountHistory extends React.Component {
|
||||
'talk-admin-account-history-header-row'
|
||||
)}
|
||||
>
|
||||
<div className={styles.headerRowItem}>Date</div>
|
||||
<div className={styles.headerRowItem}>Action</div>
|
||||
<div className={styles.headerRowItem}>Moderation</div>
|
||||
<div className={styles.headerRowItem}>
|
||||
{t('account_history.date')}
|
||||
</div>
|
||||
<div className={styles.headerRowItem}>
|
||||
{t('account_history.action')}
|
||||
</div>
|
||||
<div className={styles.headerRowItem}>
|
||||
{t('account_history.taken_by')}
|
||||
</div>
|
||||
</div>
|
||||
{userHistory.map(
|
||||
({ __typename, created_at, assigned_by, until, status }) => (
|
||||
@@ -98,10 +115,11 @@ class AccountHistory extends React.Component {
|
||||
<div
|
||||
className={cn(
|
||||
styles.item,
|
||||
styles.username,
|
||||
'talk-admin-account-history-row-assigned-by'
|
||||
)}
|
||||
>
|
||||
{getModerationValue(user.id, assigned_by)}
|
||||
{getModerationValue(assigned_by)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,14 +1,34 @@
|
||||
import { HANDLE_SUCCESSFUL_LOGIN } from 'coral-framework/constants/auth';
|
||||
import { getStaticConfiguration } from 'coral-framework/services/staticConfiguration';
|
||||
import { createPostMessage } from 'coral-framework/services/postMessage';
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Get the auth element and parse it as JSON by decoding it.
|
||||
const auth = document.getElementById('auth');
|
||||
const doc = document.implementation.createHTMLDocument('');
|
||||
doc.body.innerHTML = auth.innerText;
|
||||
try {
|
||||
const staticConfig = getStaticConfiguration();
|
||||
const { STATIC_ORIGIN: origin } = staticConfig;
|
||||
const postMessage = createPostMessage(origin);
|
||||
|
||||
// Set the item in localStorage.
|
||||
localStorage.setItem('auth', doc.body.textContent);
|
||||
// Get the auth element and parse it as JSON by decoding it.
|
||||
const auth = document.getElementById('auth');
|
||||
const doc = document.implementation.createHTMLDocument('');
|
||||
doc.body.innerHTML = auth.innerText;
|
||||
|
||||
// Close the window.
|
||||
setTimeout(() => {
|
||||
window.close();
|
||||
}, 50);
|
||||
// Auth state is contained within the node.
|
||||
const { err, data } = JSON.parse(doc.body.textContent);
|
||||
if (err) {
|
||||
// TODO: send back the error message.
|
||||
console.error(err);
|
||||
} else {
|
||||
// The data will contain a user and a token.
|
||||
const { user, token } = data;
|
||||
|
||||
// Send the state back.
|
||||
postMessage.post(HANDLE_SUCCESSFUL_LOGIN, { user, token });
|
||||
}
|
||||
} finally {
|
||||
// Always close the window.
|
||||
setTimeout(() => {
|
||||
window.close();
|
||||
}, 50);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
import * as actions from '../constants/asset';
|
||||
import { notify } from 'coral-framework/actions/notification';
|
||||
|
||||
import t from 'coral-framework/services/i18n';
|
||||
|
||||
export const fetchAssetRequest = () => ({ type: actions.FETCH_ASSET_REQUEST });
|
||||
export const fetchAssetSuccess = asset => ({
|
||||
type: actions.FETCH_ASSET_SUCCESS,
|
||||
asset,
|
||||
});
|
||||
export const fetchAssetFailure = error => ({
|
||||
type: actions.FETCH_ASSET_FAILURE,
|
||||
error,
|
||||
});
|
||||
|
||||
const updateAssetSettingsRequest = () => ({
|
||||
type: actions.UPDATE_ASSET_SETTINGS_REQUEST,
|
||||
});
|
||||
const updateAssetSettingsSuccess = settings => ({
|
||||
type: actions.UPDATE_ASSET_SETTINGS_SUCCESS,
|
||||
settings,
|
||||
});
|
||||
const updateAssetSettingsFailure = error => ({
|
||||
type: actions.UPDATE_ASSET_SETTINGS_FAILURE,
|
||||
error,
|
||||
});
|
||||
|
||||
export const updateConfiguration = newConfig => (
|
||||
dispatch,
|
||||
getState,
|
||||
{ rest }
|
||||
) => {
|
||||
const assetId = getState().asset.id;
|
||||
dispatch(updateAssetSettingsRequest());
|
||||
rest(`/assets/${assetId}/settings`, { method: 'PUT', body: newConfig })
|
||||
.then(() => {
|
||||
dispatch(notify('success', t('framework.success_update_settings')));
|
||||
dispatch(updateAssetSettingsSuccess(newConfig));
|
||||
})
|
||||
.catch(error => {
|
||||
console.error(error);
|
||||
dispatch(updateAssetSettingsFailure(error));
|
||||
});
|
||||
};
|
||||
|
||||
export const updateOpenStream = closedBody => (
|
||||
dispatch,
|
||||
getState,
|
||||
{ rest }
|
||||
) => {
|
||||
const assetId = getState().asset.id;
|
||||
dispatch(fetchAssetRequest());
|
||||
rest(`/assets/${assetId}/status`, { method: 'PUT', body: closedBody })
|
||||
.then(() => {
|
||||
dispatch(notify('success', t('framework.success_update_settings')));
|
||||
dispatch(fetchAssetSuccess(closedBody));
|
||||
})
|
||||
.catch(error => {
|
||||
console.error(error);
|
||||
dispatch(fetchAssetFailure(error));
|
||||
});
|
||||
};
|
||||
|
||||
const openStream = () => ({ type: actions.OPEN_COMMENTS });
|
||||
const closeStream = () => ({ type: actions.CLOSE_COMMENTS });
|
||||
|
||||
export const updateOpenStatus = status => dispatch => {
|
||||
if (status === 'open') {
|
||||
dispatch(openStream());
|
||||
dispatch(updateOpenStream({ closedAt: null }));
|
||||
} else {
|
||||
dispatch(closeStream());
|
||||
dispatch(updateOpenStream({ closedAt: new Date().getTime() }));
|
||||
}
|
||||
};
|
||||
@@ -1,6 +0,0 @@
|
||||
import { ADD_EXTERNAL_CONFIG } from '../constants/config';
|
||||
|
||||
export const addExternalConfig = config => ({
|
||||
type: ADD_EXTERNAL_CONFIG,
|
||||
config,
|
||||
});
|
||||
@@ -1,14 +1,10 @@
|
||||
import * as actions from '../constants/login';
|
||||
import { checkLogin } from 'coral-framework/actions/auth';
|
||||
|
||||
export const showSignInDialog = () => ({
|
||||
type: actions.SHOW_SIGNIN_DIALOG,
|
||||
});
|
||||
|
||||
export const hideSignInDialog = () => dispatch => {
|
||||
dispatch(checkLogin());
|
||||
dispatch({ type: actions.HIDE_SIGNIN_DIALOG });
|
||||
};
|
||||
export const hideSignInDialog = () => ({ type: actions.HIDE_SIGNIN_DIALOG });
|
||||
|
||||
export const focusSignInDialog = () => ({
|
||||
type: actions.FOCUS_SIGNIN_DIALOG,
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import * as actions from '../constants/profile';
|
||||
|
||||
export const setActiveTab = tab => ({ type: actions.SET_ACTIVE_TAB, tab });
|
||||
@@ -9,16 +9,12 @@ import AutomaticAssetClosure from '../containers/AutomaticAssetClosure';
|
||||
|
||||
import ExtendableTabPanel from '../containers/ExtendableTabPanel';
|
||||
import { Tab, TabPane } from 'coral-ui';
|
||||
import ProfileContainer from '../tabs/profile/containers/ProfileContainer';
|
||||
import Profile from '../tabs/profile/containers/Profile';
|
||||
import Popup from 'coral-framework/components/Popup';
|
||||
import IfSlotIsNotEmpty from 'coral-framework/components/IfSlotIsNotEmpty';
|
||||
import cn from 'classnames';
|
||||
|
||||
export default class Embed extends React.Component {
|
||||
changeTab = tab => {
|
||||
this.props.setActiveTab(tab);
|
||||
};
|
||||
|
||||
getTabs() {
|
||||
const tabs = [
|
||||
<Tab
|
||||
@@ -53,6 +49,7 @@ export default class Embed extends React.Component {
|
||||
render() {
|
||||
const {
|
||||
activeTab,
|
||||
setActiveTab,
|
||||
commentId,
|
||||
root,
|
||||
root: { asset },
|
||||
@@ -65,6 +62,7 @@ export default class Embed extends React.Component {
|
||||
parentUrl,
|
||||
} = this.props;
|
||||
const hasHighlightedComment = !!commentId;
|
||||
const popupUrl = `login?parentUrl=${encodeURIComponent(parentUrl)}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -75,7 +73,7 @@ export default class Embed extends React.Component {
|
||||
<AutomaticAssetClosure asset={asset} />
|
||||
<IfSlotIsNotEmpty slot="login">
|
||||
<Popup
|
||||
href={`login?parentUrl=${encodeURIComponent(parentUrl)}`}
|
||||
href={popupUrl}
|
||||
title="Login"
|
||||
features="menubar=0,resizable=0,width=500,height=550,top=200,left=500"
|
||||
open={showSignInDialog}
|
||||
@@ -91,7 +89,7 @@ export default class Embed extends React.Component {
|
||||
<ExtendableTabPanel
|
||||
className="talk-embed-stream-tab-bar"
|
||||
activeTab={activeTab}
|
||||
setActiveTab={this.changeTab}
|
||||
setActiveTab={setActiveTab}
|
||||
fallbackTab="stream"
|
||||
tabSlot="embedStreamTabs"
|
||||
tabSlotPrepend="embedStreamTabsPrepend"
|
||||
@@ -112,7 +110,7 @@ export default class Embed extends React.Component {
|
||||
tabId="profile"
|
||||
className="talk-embed-stream-profile-tab-pane"
|
||||
>
|
||||
<ProfileContainer />
|
||||
<Profile />
|
||||
</TabPane>,
|
||||
<TabPane
|
||||
key="config"
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
export const FETCH_ASSET_REQUEST = 'FETCH_ASSET_REQUEST';
|
||||
export const FETCH_ASSET_FAILURE = 'FETCH_ASSET_FAILURE';
|
||||
export const FETCH_ASSET_SUCCESS = 'FETCH_ASSET_SUCCESS';
|
||||
|
||||
export const UPDATE_ASSET_SETTINGS_REQUEST = 'UPDATE_ASSET_SETTINGS_REQUEST';
|
||||
export const UPDATE_ASSET_SETTINGS_SUCCESS = 'UPDATE_ASSET_SETTINGS_SUCCESS';
|
||||
export const UPDATE_ASSET_SETTINGS_FAILURE = 'UPDATE_ASSET_SETTINGS_FAILURE';
|
||||
|
||||
export const OPEN_COMMENTS = 'OPEN_COMMENTS';
|
||||
export const CLOSE_COMMENTS = 'CLOSE_COMMENTS';
|
||||
@@ -1 +0,0 @@
|
||||
export const ADD_EXTERNAL_CONFIG = 'ADD_EXTERNAL_CONFIG';
|
||||
@@ -0,0 +1,2 @@
|
||||
const prefix = 'TALK_EMBED_STREAM';
|
||||
export const SET_ACTIVE_TAB = `${prefix}_SET_ACTIVE_TAB`;
|
||||
@@ -2,7 +2,6 @@ import React from 'react';
|
||||
import { compose, gql } from 'react-apollo';
|
||||
import { connect } from 'react-redux';
|
||||
import { bindActionCreators } from 'redux';
|
||||
import isEqual from 'lodash/isEqual';
|
||||
import get from 'lodash/get';
|
||||
import branch from 'recompose/branch';
|
||||
import renderComponent from 'recompose/renderComponent';
|
||||
@@ -14,12 +13,11 @@ import {
|
||||
hideSignInDialog,
|
||||
} from '../actions/login';
|
||||
import { updateStatus } from 'coral-framework/actions/auth';
|
||||
import { fetchAssetSuccess } from '../actions/asset';
|
||||
import {
|
||||
getDefinitionName,
|
||||
getSlotFragmentSpreads,
|
||||
} from 'coral-framework/utils';
|
||||
import { withQuery } from 'coral-framework/hocs';
|
||||
import { withQuery, withPopupAuthHandler } from 'coral-framework/hocs';
|
||||
import Embed from '../components/Embed';
|
||||
import Stream from '../tabs/stream/containers/Stream';
|
||||
import AutomaticAssetClosure from './AutomaticAssetClosure';
|
||||
@@ -107,12 +105,6 @@ class EmbedContainer extends React.Component {
|
||||
this.props.data.refetch();
|
||||
this.resubscribe(nextProps);
|
||||
}
|
||||
|
||||
const { fetchAssetSuccess } = this.props;
|
||||
if (!isEqual(nextProps.root.asset, this.props.root.asset)) {
|
||||
// TODO: remove asset data from redux store.
|
||||
fetchAssetSuccess(nextProps.root.asset);
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
@@ -298,7 +290,6 @@ EmbedContainer.propTypes = {
|
||||
activeTab: PropTypes.string,
|
||||
parentUrl: PropTypes.string,
|
||||
data: PropTypes.object,
|
||||
fetchAssetSuccess: PropTypes.func,
|
||||
showSignInDialog: PropTypes.bool,
|
||||
signInDialogFocus: PropTypes.bool,
|
||||
};
|
||||
@@ -322,7 +313,6 @@ const mapDispatchToProps = dispatch =>
|
||||
bindActionCreators(
|
||||
{
|
||||
setActiveTab,
|
||||
fetchAssetSuccess,
|
||||
notify,
|
||||
focusSignInDialog,
|
||||
blurSignInDialog,
|
||||
@@ -333,6 +323,7 @@ const mapDispatchToProps = dispatch =>
|
||||
);
|
||||
|
||||
export default compose(
|
||||
withPopupAuthHandler,
|
||||
connect(mapStateToProps, mapDispatchToProps),
|
||||
branch(props => !props.checkedInitialLogin, renderComponent(Spinner)),
|
||||
withEmbedQuery
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import * as actions from '../constants/asset';
|
||||
|
||||
const initialState = {
|
||||
closedAt: null,
|
||||
settings: null,
|
||||
title: null,
|
||||
url: null,
|
||||
features: {},
|
||||
status: 'open',
|
||||
moderation: null,
|
||||
};
|
||||
|
||||
export default function asset(state = initialState, action) {
|
||||
switch (action.type) {
|
||||
case actions.FETCH_ASSET_SUCCESS:
|
||||
return {
|
||||
...state,
|
||||
...action.asset,
|
||||
};
|
||||
case actions.UPDATE_ASSET_SETTINGS_SUCCESS:
|
||||
return {
|
||||
...state,
|
||||
settings: action.settings,
|
||||
};
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { ADD_EXTERNAL_CONFIG } from '../constants/config';
|
||||
|
||||
const initialState = {};
|
||||
|
||||
export default function config(state = initialState, action) {
|
||||
switch (action.type) {
|
||||
case ADD_EXTERNAL_CONFIG:
|
||||
return {
|
||||
...state,
|
||||
...action.config,
|
||||
};
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
import login from './login';
|
||||
import asset from './asset';
|
||||
import embed from './embed';
|
||||
import configure from './configure';
|
||||
import stream from './stream';
|
||||
import profile from './profile';
|
||||
|
||||
export default {
|
||||
login,
|
||||
asset,
|
||||
embed,
|
||||
configure,
|
||||
stream,
|
||||
profile,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import * as actions from '../constants/profile';
|
||||
|
||||
const initialState = {
|
||||
activeTab: 'comments',
|
||||
previousTab: '',
|
||||
};
|
||||
|
||||
export default function stream(state = initialState, action) {
|
||||
switch (action.type) {
|
||||
case actions.SET_ACTIVE_TAB:
|
||||
return {
|
||||
...state,
|
||||
activeTab: action.tab,
|
||||
previousTab: state.activeTab,
|
||||
};
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
@@ -11,8 +11,18 @@ import { getTotalReactionsCount } from 'coral-framework/utils';
|
||||
import t from 'coral-framework/services/i18n';
|
||||
|
||||
class Comment extends React.Component {
|
||||
goToStory = () => {
|
||||
this.props.navigate(this.props.comment.asset.url);
|
||||
};
|
||||
|
||||
goToConversation = () => {
|
||||
this.props.navigate(
|
||||
`${this.props.comment.asset.url}?commentId=${this.props.comment.id}`
|
||||
);
|
||||
};
|
||||
|
||||
render() {
|
||||
const { comment, link, data, root } = this.props;
|
||||
const { comment, data, root } = this.props;
|
||||
const reactionCount = getTotalReactionsCount(comment.action_summaries);
|
||||
const queryData = { root, comment, asset: comment.asset };
|
||||
|
||||
@@ -67,7 +77,7 @@ class Comment extends React.Component {
|
||||
<a
|
||||
className={cn(styles.assetURL, 'my-comment-anchor')}
|
||||
href="#"
|
||||
onClick={link(`${comment.asset.url}`)}
|
||||
onClick={this.goToStory}
|
||||
>
|
||||
{t('common.story')}:{' '}
|
||||
{comment.asset.title ? comment.asset.title : comment.asset.url}
|
||||
@@ -77,10 +87,7 @@ class Comment extends React.Component {
|
||||
<div className={styles.sidebar}>
|
||||
<ul>
|
||||
<li>
|
||||
<a
|
||||
onClick={link(`${comment.asset.url}?commentId=${comment.id}`)}
|
||||
className={styles.viewLink}
|
||||
>
|
||||
<a onClick={this.goToConversation} className={styles.viewLink}>
|
||||
<Icon name="open_in_new" className={styles.iconView} />
|
||||
{t('view_conversation')}
|
||||
</a>
|
||||
@@ -105,10 +112,10 @@ class Comment extends React.Component {
|
||||
}
|
||||
|
||||
Comment.propTypes = {
|
||||
comment: PropTypes.shape({
|
||||
id: PropTypes.string,
|
||||
body: PropTypes.string,
|
||||
}).isRequired,
|
||||
comment: PropTypes.object.isRequired,
|
||||
navigate: PropTypes.func.isRequired,
|
||||
data: PropTypes.object.isRequired,
|
||||
root: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
export default Comment;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import Comment from './Comment';
|
||||
import Comment from '../containers/Comment';
|
||||
import LoadMore from './LoadMore';
|
||||
|
||||
class CommentHistory extends React.Component {
|
||||
@@ -21,9 +21,9 @@ class CommentHistory extends React.Component {
|
||||
};
|
||||
|
||||
render() {
|
||||
const { link, comments, data, root } = this.props;
|
||||
const { navigate, comments, data, root } = this.props;
|
||||
return (
|
||||
<div>
|
||||
<div className="talk-my-profile-comment-history">
|
||||
<div className="commentHistory__list">
|
||||
{comments.nodes.map((comment, i) => {
|
||||
return (
|
||||
@@ -32,7 +32,7 @@ class CommentHistory extends React.Component {
|
||||
data={data}
|
||||
root={root}
|
||||
comment={comment}
|
||||
link={link}
|
||||
navigate={navigate}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -51,7 +51,7 @@ class CommentHistory extends React.Component {
|
||||
CommentHistory.propTypes = {
|
||||
comments: PropTypes.object.isRequired,
|
||||
loadMore: PropTypes.func,
|
||||
link: PropTypes.func,
|
||||
navigate: PropTypes.func,
|
||||
data: PropTypes.object,
|
||||
root: PropTypes.object,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
.userInfo {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.email {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.username {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import Slot from 'coral-framework/components/Slot';
|
||||
import CommentHistory from '../containers/CommentHistory';
|
||||
import ExtendableTabPanel from '../../../containers/ExtendableTabPanel';
|
||||
import { Tab, TabPane } from 'coral-ui';
|
||||
import styles from './Profile.css';
|
||||
import t from 'coral-framework/services/i18n';
|
||||
|
||||
const Profile = ({
|
||||
username,
|
||||
emailAddress,
|
||||
data,
|
||||
root,
|
||||
activeTab,
|
||||
setActiveTab,
|
||||
}) => (
|
||||
<div className="talk-my-profile talk-profile-container">
|
||||
<div className={styles.userInfo}>
|
||||
<h2 className={styles.username}>{username}</h2>
|
||||
{emailAddress ? <p className={styles.email}>{emailAddress}</p> : null}
|
||||
</div>
|
||||
<Slot fill="profileSections" data={data} queryData={{ root }} />
|
||||
<ExtendableTabPanel
|
||||
activeTab={activeTab}
|
||||
setActiveTab={setActiveTab}
|
||||
fallbackTab="comments"
|
||||
tabSlot="profileTabs"
|
||||
tabSlotPrepend="profileTabsPrepend"
|
||||
tabPaneSlot="profileTabPanes"
|
||||
slotProps={{ data }}
|
||||
queryData={{ root }}
|
||||
tabs={[
|
||||
<Tab key="comments" tabId="comments">
|
||||
{t('framework.my_comments')}
|
||||
</Tab>,
|
||||
]}
|
||||
tabPanes={[
|
||||
<TabPane key="comments" tabId="comments">
|
||||
<CommentHistory data={data} root={root} />
|
||||
</TabPane>,
|
||||
]}
|
||||
sub
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
Profile.propTypes = {
|
||||
username: PropTypes.string,
|
||||
emailAddress: PropTypes.string,
|
||||
data: PropTypes.object,
|
||||
root: PropTypes.object,
|
||||
activeTab: PropTypes.string.isRequired,
|
||||
setActiveTab: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default Profile;
|
||||
@@ -0,0 +1,32 @@
|
||||
import { gql, compose } from 'react-apollo';
|
||||
import Comment from '../components/Comment';
|
||||
import { withFragments } from 'coral-framework/hocs';
|
||||
import { getSlotFragmentSpreads } from 'coral-framework/utils';
|
||||
|
||||
const slots = ['commentContent', 'historyCommentTimestamp'];
|
||||
|
||||
const withCommentFragments = withFragments({
|
||||
comment: gql`
|
||||
fragment TalkEmbedStream_ProfileComment_comment on Comment {
|
||||
id
|
||||
body
|
||||
replyCount
|
||||
action_summaries {
|
||||
count
|
||||
__typename
|
||||
}
|
||||
asset {
|
||||
id
|
||||
title
|
||||
url
|
||||
${getSlotFragmentSpreads(slots, 'asset')}
|
||||
}
|
||||
created_at
|
||||
${getSlotFragmentSpreads(slots, 'comment')}
|
||||
}
|
||||
`,
|
||||
});
|
||||
|
||||
const enhance = compose(withCommentFragments);
|
||||
|
||||
export default enhance(Comment);
|
||||
@@ -0,0 +1,103 @@
|
||||
import React, { Component } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { connect } from 'react-redux';
|
||||
import { compose, gql } from 'react-apollo';
|
||||
import CommentHistory from '../components/CommentHistory';
|
||||
import Comment from './Comment';
|
||||
import { withFragments } from 'coral-framework/hocs';
|
||||
|
||||
import { appendNewNodes } from 'plugin-api/beta/client/utils';
|
||||
import update from 'immutability-helper';
|
||||
import { getDefinitionName } from 'coral-framework/utils';
|
||||
|
||||
class CommentHistoryContainer extends Component {
|
||||
navigate = url => {
|
||||
this.context.pym.sendMessage('navigate', url);
|
||||
};
|
||||
|
||||
loadMore = () => {
|
||||
return this.props.data.fetchMore({
|
||||
query: LOAD_MORE_QUERY,
|
||||
variables: {
|
||||
limit: 5,
|
||||
cursor: this.props.root.me.comments.endCursor,
|
||||
},
|
||||
updateQuery: (previous, { fetchMoreResult: { me: { comments } } }) => {
|
||||
const updated = update(previous, {
|
||||
me: {
|
||||
comments: {
|
||||
nodes: {
|
||||
$apply: nodes => appendNewNodes(nodes, comments.nodes),
|
||||
},
|
||||
hasNextPage: { $set: comments.hasNextPage },
|
||||
endCursor: { $set: comments.endCursor },
|
||||
},
|
||||
},
|
||||
});
|
||||
return updated;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<CommentHistory
|
||||
comments={this.props.root.me.comments}
|
||||
data={this.props.data}
|
||||
root={this.props.root}
|
||||
loadMore={this.loadMore}
|
||||
navigate={this.navigate}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
CommentHistoryContainer.contextTypes = {
|
||||
pym: PropTypes.object,
|
||||
};
|
||||
|
||||
CommentHistoryContainer.propTypes = {
|
||||
data: PropTypes.object,
|
||||
root: PropTypes.object,
|
||||
};
|
||||
|
||||
const LOAD_MORE_QUERY = gql`
|
||||
query TalkEmbedStream_CommentHistory_LoadMoreComments($limit: Int, $cursor: Cursor) {
|
||||
me {
|
||||
comments(query: { limit: $limit, cursor: $cursor }) {
|
||||
nodes {
|
||||
...${getDefinitionName(Comment.fragments.comment)}
|
||||
}
|
||||
endCursor
|
||||
hasNextPage
|
||||
}
|
||||
}
|
||||
}
|
||||
${Comment.fragments.comment}
|
||||
`;
|
||||
|
||||
const withCommentHistoryFragments = withFragments({
|
||||
root: gql`
|
||||
fragment TalkEmbedStream_CommentHistory on RootQuery {
|
||||
me {
|
||||
comments(query: {limit: 10}) {
|
||||
nodes {
|
||||
...${getDefinitionName(Comment.fragments.comment)}
|
||||
}
|
||||
endCursor
|
||||
hasNextPage
|
||||
}
|
||||
}
|
||||
}
|
||||
${Comment.fragments.comment}
|
||||
`,
|
||||
});
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
currentUser: state.auth.user,
|
||||
});
|
||||
|
||||
export default compose(
|
||||
connect(mapStateToProps, null),
|
||||
withCommentHistoryFragments
|
||||
)(CommentHistoryContainer);
|
||||
@@ -0,0 +1,104 @@
|
||||
import React, { Component } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { connect } from 'react-redux';
|
||||
import { compose, gql } from 'react-apollo';
|
||||
import { bindActionCreators } from 'redux';
|
||||
import { withQuery } from 'coral-framework/hocs';
|
||||
import NotLoggedIn from '../components/NotLoggedIn';
|
||||
import { Spinner } from 'coral-ui';
|
||||
import Profile from '../components/Profile';
|
||||
import CommentHistory from './CommentHistory';
|
||||
import { getDefinitionName } from 'coral-framework/utils';
|
||||
|
||||
import { showSignInDialog } from 'coral-embed-stream/src/actions/login';
|
||||
import { setActiveTab } from '../../../actions/profile';
|
||||
import { getSlotFragmentSpreads } from 'coral-framework/utils';
|
||||
|
||||
class ProfileContainer extends Component {
|
||||
componentWillReceiveProps(nextProps) {
|
||||
if (!this.props.currentUser && nextProps.currentUser) {
|
||||
// Refetch after login.
|
||||
this.props.data.refetch();
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { currentUser, showSignInDialog, root, data } = this.props;
|
||||
const { me } = this.props.root;
|
||||
const loading = this.props.data.loading;
|
||||
|
||||
if (this.props.data.error) {
|
||||
return <div>{this.props.data.error.message}</div>;
|
||||
}
|
||||
|
||||
if (!currentUser) {
|
||||
return <NotLoggedIn showSignInDialog={showSignInDialog} />;
|
||||
}
|
||||
|
||||
if (loading || !me) {
|
||||
return <Spinner />;
|
||||
}
|
||||
|
||||
const localProfile = currentUser.profiles.find(p => p.provider === 'local');
|
||||
const emailAddress = localProfile && localProfile.id;
|
||||
|
||||
return (
|
||||
<Profile
|
||||
username={me.username}
|
||||
emailAddress={emailAddress}
|
||||
data={data}
|
||||
root={root}
|
||||
activeTab={this.props.activeTab}
|
||||
setActiveTab={this.props.setActiveTab}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ProfileContainer.propTypes = {
|
||||
data: PropTypes.object,
|
||||
root: PropTypes.object,
|
||||
currentUser: PropTypes.object,
|
||||
showSignInDialog: PropTypes.func,
|
||||
activeTab: PropTypes.string.isRequired,
|
||||
setActiveTab: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
const slots = [
|
||||
'profileSections',
|
||||
'profileTabs',
|
||||
'profileTabsPrepend',
|
||||
'profileTabPanes',
|
||||
];
|
||||
|
||||
const withProfileQuery = withQuery(
|
||||
gql`
|
||||
query CoralEmbedStream_Profile {
|
||||
me {
|
||||
id
|
||||
username
|
||||
}
|
||||
...${getDefinitionName(CommentHistory.fragments.root)}
|
||||
${getSlotFragmentSpreads(slots, 'root')}
|
||||
}
|
||||
${CommentHistory.fragments.root}
|
||||
`,
|
||||
{
|
||||
options: {
|
||||
fetchPolicy: 'network-only',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
currentUser: state.auth.user,
|
||||
activeTab: state.profile.activeTab,
|
||||
});
|
||||
|
||||
const mapDispatchToProps = dispatch =>
|
||||
bindActionCreators({ showSignInDialog, setActiveTab }, dispatch);
|
||||
|
||||
export default compose(
|
||||
connect(mapStateToProps, mapDispatchToProps),
|
||||
withProfileQuery
|
||||
)(ProfileContainer);
|
||||
@@ -1,178 +0,0 @@
|
||||
import { connect } from 'react-redux';
|
||||
import { compose, gql } from 'react-apollo';
|
||||
import React, { Component } from 'react';
|
||||
import { bindActionCreators } from 'redux';
|
||||
import { withQuery } from 'coral-framework/hocs';
|
||||
import Slot from 'coral-framework/components/Slot';
|
||||
import cn from 'classnames';
|
||||
import { link } from 'coral-framework/services/pym';
|
||||
import NotLoggedIn from '../components/NotLoggedIn';
|
||||
import { Spinner } from 'coral-ui';
|
||||
import CommentHistory from '../components/CommentHistory';
|
||||
|
||||
import { showSignInDialog } from 'coral-embed-stream/src/actions/login';
|
||||
import { appendNewNodes } from 'plugin-api/beta/client/utils';
|
||||
import update from 'immutability-helper';
|
||||
import { getSlotFragmentSpreads } from 'coral-framework/utils';
|
||||
|
||||
import t from 'coral-framework/services/i18n';
|
||||
|
||||
class ProfileContainer extends Component {
|
||||
componentWillReceiveProps(nextProps) {
|
||||
if (!this.props.currentUser && nextProps.currentUser) {
|
||||
// Refetch after login.
|
||||
this.props.data.refetch();
|
||||
}
|
||||
}
|
||||
|
||||
loadMore = () => {
|
||||
return this.props.data.fetchMore({
|
||||
query: LOAD_MORE_QUERY,
|
||||
variables: {
|
||||
limit: 5,
|
||||
cursor: this.props.root.me.comments.endCursor,
|
||||
},
|
||||
updateQuery: (previous, { fetchMoreResult: { me: { comments } } }) => {
|
||||
const updated = update(previous, {
|
||||
me: {
|
||||
comments: {
|
||||
nodes: {
|
||||
$apply: nodes => appendNewNodes(nodes, comments.nodes),
|
||||
},
|
||||
hasNextPage: { $set: comments.hasNextPage },
|
||||
endCursor: { $set: comments.endCursor },
|
||||
},
|
||||
},
|
||||
});
|
||||
return updated;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
const { currentUser, showSignInDialog, root, data } = this.props;
|
||||
const { me } = this.props.root;
|
||||
const loading = this.props.data.loading;
|
||||
|
||||
if (this.props.data.error) {
|
||||
return <div>{this.props.data.error.message}</div>;
|
||||
}
|
||||
|
||||
if (!currentUser) {
|
||||
return <NotLoggedIn showSignInDialog={showSignInDialog} />;
|
||||
}
|
||||
|
||||
if (loading || !me) {
|
||||
return <Spinner />;
|
||||
}
|
||||
|
||||
const localProfile = currentUser.profiles.find(p => p.provider === 'local');
|
||||
const emailAddress = localProfile && localProfile.id;
|
||||
|
||||
return (
|
||||
<div className="talk-my-profile talk-embed-stream-profile-container">
|
||||
<h2>{me.username}</h2>
|
||||
{emailAddress ? <p>{emailAddress}</p> : null}
|
||||
<Slot fill="profileSections" data={data} queryData={{ root }} />
|
||||
<hr />
|
||||
<h3>{t('framework.my_comments')}</h3>
|
||||
<div
|
||||
className={cn('talk-my-profile-comment-history', {
|
||||
'talk-my-profile-comment-history-no-comments': !me.comments.nodes
|
||||
.length,
|
||||
})}
|
||||
>
|
||||
{me.comments.nodes.length ? (
|
||||
<CommentHistory
|
||||
data={data}
|
||||
root={root}
|
||||
comments={me.comments}
|
||||
link={link}
|
||||
loadMore={this.loadMore}
|
||||
/>
|
||||
) : (
|
||||
<p className="talk-my-profile-comment-history-no-comments-cta">
|
||||
{t('user_no_comment')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const slots = [
|
||||
'profileSections',
|
||||
|
||||
// TODO: These Slots should be included in `talk-plugin-history` instead.
|
||||
'commentContent',
|
||||
'historyCommentTimestamp',
|
||||
];
|
||||
|
||||
const CommentFragment = gql`
|
||||
fragment TalkSettings_CommentConnectionFragment on CommentConnection {
|
||||
nodes {
|
||||
id
|
||||
body
|
||||
replyCount
|
||||
action_summaries {
|
||||
count
|
||||
__typename
|
||||
}
|
||||
asset {
|
||||
id
|
||||
title
|
||||
url
|
||||
${getSlotFragmentSpreads(slots, 'asset')}
|
||||
}
|
||||
created_at
|
||||
${getSlotFragmentSpreads(slots, 'comment')}
|
||||
}
|
||||
endCursor
|
||||
hasNextPage
|
||||
}
|
||||
`;
|
||||
|
||||
const LOAD_MORE_QUERY = gql`
|
||||
query TalkSettings_LoadMoreComments($limit: Int, $cursor: Cursor) {
|
||||
me {
|
||||
comments(query: { limit: $limit, cursor: $cursor }) {
|
||||
...TalkSettings_CommentConnectionFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
${CommentFragment}
|
||||
`;
|
||||
|
||||
const withProfileQuery = withQuery(
|
||||
gql`
|
||||
query CoralEmbedStream_Profile {
|
||||
me {
|
||||
id
|
||||
username
|
||||
comments(query: {limit: 10}) {
|
||||
...TalkSettings_CommentConnectionFragment
|
||||
}
|
||||
}
|
||||
${getSlotFragmentSpreads(slots, 'root')}
|
||||
}
|
||||
${CommentFragment}
|
||||
`,
|
||||
{
|
||||
options: {
|
||||
fetchPolicy: 'network-only',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
currentUser: state.auth.user,
|
||||
});
|
||||
|
||||
const mapDispatchToProps = dispatch =>
|
||||
bindActionCreators({ showSignInDialog }, dispatch);
|
||||
|
||||
export default compose(
|
||||
connect(mapStateToProps, mapDispatchToProps),
|
||||
withProfileQuery
|
||||
)(ProfileContainer);
|
||||
@@ -604,6 +604,7 @@ export default class Comment extends React.Component {
|
||||
defaultComponent={CommentContent}
|
||||
{...slotProps}
|
||||
queryData={queryData}
|
||||
slotSize={1}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -16,7 +16,7 @@ function parseAssetURL() {
|
||||
// Try to get the url from the canonical tag on the page.
|
||||
return document.querySelector('link[rel="canonical"]').href;
|
||||
} catch (e) {
|
||||
console.warn(
|
||||
window.console.warn(
|
||||
'This page does not include a canonical link tag. Talk has inferred this asset_url from the window object. Query params have been stripped, which may cause a single thread to be present across multiple pages.'
|
||||
);
|
||||
|
||||
|
||||
@@ -63,13 +63,25 @@ export const setAuthToken = token => (dispatch, _, { localStorage }) => {
|
||||
export const handleSuccessfulLogin = (user, token) => (
|
||||
dispatch,
|
||||
_,
|
||||
{ client, localStorage }
|
||||
{ client, localStorage, postMessage }
|
||||
) => {
|
||||
const { exp } = jwtDecode(token);
|
||||
|
||||
if (localStorage) {
|
||||
localStorage.setItem('exp', jwtDecode(token).exp);
|
||||
localStorage.setItem('exp', exp);
|
||||
localStorage.setItem('token', token);
|
||||
}
|
||||
|
||||
// Send the message via the messages service to the window.opener if it
|
||||
// exists.
|
||||
if (window.opener) {
|
||||
postMessage.post(
|
||||
actions.HANDLE_SUCCESSFUL_LOGIN,
|
||||
{ user, token },
|
||||
window.opener
|
||||
);
|
||||
}
|
||||
|
||||
client.resetWebsocket();
|
||||
|
||||
dispatch({
|
||||
|
||||
@@ -7,6 +7,7 @@ import PropTypes from 'prop-types';
|
||||
import isEqual from 'lodash/isEqual';
|
||||
import get from 'lodash/get';
|
||||
import { getShallowChanges } from 'coral-framework/utils';
|
||||
import omit from 'lodash/omit';
|
||||
|
||||
const emptyConfig = {};
|
||||
|
||||
@@ -32,27 +33,29 @@ class Slot extends React.Component {
|
||||
}
|
||||
|
||||
getSlotProps(props = this.props) {
|
||||
const {
|
||||
fill: _a,
|
||||
inline: _b,
|
||||
className: _c,
|
||||
reduxState: _d,
|
||||
defaultComponent_: _e,
|
||||
queryData: _f,
|
||||
childFactory: _g,
|
||||
component: _h,
|
||||
...rest
|
||||
} = props;
|
||||
return rest;
|
||||
return omit(props, [
|
||||
'fill',
|
||||
'inline',
|
||||
'className',
|
||||
'reduxState',
|
||||
'slotSize',
|
||||
'defaultComponent',
|
||||
'queryData',
|
||||
'childFactory',
|
||||
'component',
|
||||
]);
|
||||
}
|
||||
|
||||
getChildren(props = this.props) {
|
||||
const { slotSize = 0 } = props;
|
||||
const { plugins } = this.context;
|
||||
|
||||
return plugins.getSlotElements(
|
||||
props.fill,
|
||||
props.reduxState,
|
||||
this.getSlotProps(props),
|
||||
props.queryData
|
||||
props.queryData,
|
||||
{ slotSize }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -110,6 +113,11 @@ Slot.propTypes = {
|
||||
reduxState: PropTypes.object,
|
||||
defaultComponent: PropTypes.oneOfType([PropTypes.func, PropTypes.string]),
|
||||
|
||||
/**
|
||||
* Specifies the number of children that can fill the slot.
|
||||
*/
|
||||
slotSize: PropTypes.number,
|
||||
|
||||
/**
|
||||
* You may specify the component to use as the root wrapper.
|
||||
* Defaults to 'div'.
|
||||
|
||||
@@ -17,6 +17,7 @@ class TalkProvider extends React.Component {
|
||||
store: this.props.store,
|
||||
pymLocalStorage: this.props.pymLocalStorage,
|
||||
pymSessionStorage: this.props.pymSessionStorage,
|
||||
postMessage: this.props.postMessage,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -39,6 +40,7 @@ TalkProvider.childContextTypes = {
|
||||
pymSessionStorage: PropTypes.object,
|
||||
history: PropTypes.object,
|
||||
store: PropTypes.object,
|
||||
postMessage: PropTypes.object,
|
||||
};
|
||||
|
||||
TalkProvider.propTypes = TalkProvider.childContextTypes;
|
||||
|
||||
@@ -698,7 +698,7 @@ export const withCloseAsset = withMutation(
|
||||
const fragmentId = `Asset_${id}`;
|
||||
const data = {
|
||||
__typename: 'Asset',
|
||||
closedAt: new Date(),
|
||||
closedAt: new Date().toISOString(),
|
||||
isClosed: true,
|
||||
};
|
||||
proxy.writeFragment({ fragment, id: fragmentId, data });
|
||||
|
||||
@@ -10,6 +10,7 @@ export { default as withSignIn } from './withSignIn';
|
||||
export { default as withSignUp } from './withSignUp';
|
||||
export { default as withForgotPassword } from './withForgotPassword';
|
||||
export { default as withSetUsername } from './withSetUsername';
|
||||
export { default as withPopupAuthHandler } from './withPopupAuthHandler';
|
||||
export {
|
||||
default as withResendEmailConfirmation,
|
||||
} from './withResendEmailConfirmation';
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import React from 'react';
|
||||
import hoistStatics from 'recompose/hoistStatics';
|
||||
import PropTypes from 'prop-types';
|
||||
import { HANDLE_SUCCESSFUL_LOGIN } from 'coral-framework/constants/auth';
|
||||
import {
|
||||
handleSuccessfulLogin,
|
||||
setAuthToken,
|
||||
} from 'coral-framework/actions/auth';
|
||||
|
||||
/**
|
||||
* WithPopupAuthHandler listens to successful logins over
|
||||
* the `postMessage` service.
|
||||
*/
|
||||
export default hoistStatics(WrappedComponent => {
|
||||
class WithPopupAuthHandler extends React.Component {
|
||||
static contextTypes = {
|
||||
store: PropTypes.object,
|
||||
postMessage: PropTypes.object,
|
||||
};
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
context.postMessage.subscribe(this.handleAuth);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.context.postMessage.unsubscribe(this.handleAuth);
|
||||
}
|
||||
|
||||
handleAuth = ({ name, data }) => {
|
||||
if (name !== HANDLE_SUCCESSFUL_LOGIN) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { store } = this.context;
|
||||
// data will contain the user and token.
|
||||
const { user, token } = data;
|
||||
|
||||
if (user && token) {
|
||||
store.dispatch(handleSuccessfulLogin(user, token));
|
||||
} else if (token) {
|
||||
store.dispatch(setAuthToken(token));
|
||||
} else {
|
||||
console.error('Invalid auth data supplied', data);
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
return <WrappedComponent {...this.props} />;
|
||||
}
|
||||
}
|
||||
|
||||
return WithPopupAuthHandler;
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
export default class Action {
|
||||
listeners = [];
|
||||
|
||||
listen = cb => {
|
||||
this.listeners.push(cb);
|
||||
};
|
||||
|
||||
unlisten = cb => {
|
||||
this.listeners = this.listeners.filter(i => i !== cb);
|
||||
};
|
||||
|
||||
event = { listen: this.listen, unlisten: this.unlisten };
|
||||
|
||||
call(...args) {
|
||||
this.listeners.forEach(cb => cb(...args));
|
||||
}
|
||||
|
||||
asEvent() {
|
||||
return this.event;
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,15 @@
|
||||
import { MERGE_CONFIG } from '../constants/config';
|
||||
import { LOGOUT } from '../constants/auth';
|
||||
|
||||
const initialState = {};
|
||||
|
||||
export default function config(state = initialState, action) {
|
||||
switch (action.type) {
|
||||
case LOGOUT:
|
||||
return {
|
||||
...state,
|
||||
auth_token: null,
|
||||
};
|
||||
case MERGE_CONFIG:
|
||||
return {
|
||||
...state,
|
||||
|
||||
+5
-1
@@ -14,6 +14,7 @@ import { createPluginsService } from './plugins';
|
||||
import { createNotificationService } from './notification';
|
||||
import { createGraphQLRegistry } from './graphqlRegistry';
|
||||
import { createGraphQLService } from './graphql';
|
||||
import { createPostMessage } from './postMessage';
|
||||
import globalFragments from 'coral-framework/graphql/fragments';
|
||||
import {
|
||||
createStorage,
|
||||
@@ -118,7 +119,7 @@ export async function createContext({
|
||||
});
|
||||
|
||||
const staticConfig = getStaticConfiguration();
|
||||
let { LIVE_URI: liveUri } = staticConfig;
|
||||
let { LIVE_URI: liveUri, STATIC_ORIGIN: origin } = staticConfig;
|
||||
if (liveUri == null) {
|
||||
// The protocol must match the origin protocol, secure/insecure.
|
||||
const protocol = location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
@@ -128,6 +129,8 @@ export async function createContext({
|
||||
liveUri = `${protocol}://${location.host}${BASE_PATH}api/v1/live`;
|
||||
}
|
||||
|
||||
const postMessage = createPostMessage(origin);
|
||||
|
||||
const client = createClient({
|
||||
uri: `${BASE_PATH}api/v1/graph/ql`,
|
||||
liveUri,
|
||||
@@ -158,6 +161,7 @@ export async function createContext({
|
||||
pymLocalStorage,
|
||||
pymSessionStorage,
|
||||
inIframe,
|
||||
postMessage,
|
||||
};
|
||||
|
||||
// Load framework fragments.
|
||||
|
||||
@@ -97,8 +97,9 @@ class PluginsService {
|
||||
/**
|
||||
* Returns React Elements for given slot.
|
||||
*/
|
||||
getSlotElements(slot, reduxState, props = {}, queryData = {}) {
|
||||
getSlotElements(slot, reduxState, props = {}, queryData = {}, options = {}) {
|
||||
const pluginConfig = get(reduxState, 'config.plugin_config') || emptyConfig;
|
||||
const { slotSize = 0 } = options;
|
||||
|
||||
const isDisabled = component => {
|
||||
if (
|
||||
@@ -129,11 +130,21 @@ class PluginsService {
|
||||
return false;
|
||||
};
|
||||
|
||||
return flatten(
|
||||
const slots = flatten(
|
||||
this.plugins
|
||||
.filter(o => o.module.slots && o.module.slots[slot])
|
||||
.map(o => o.module.slots[slot])
|
||||
)
|
||||
);
|
||||
|
||||
if (slotSize > 0 && slots.length > slotSize) {
|
||||
console.warn(
|
||||
`Slot[${slot}] supports a maximum of ${slotSize} plugins providing slots, got ${
|
||||
slots.length
|
||||
}, will only use the first ${slotSize}`
|
||||
);
|
||||
}
|
||||
|
||||
return (slotSize > 0 ? slots.slice(0, slotSize) : slots)
|
||||
.map((component, i) => ({
|
||||
component,
|
||||
disabled: isDisabled(component),
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import set from 'lodash/set';
|
||||
import has from 'lodash/has';
|
||||
import get from 'lodash/get';
|
||||
import remove from 'lodash/remove';
|
||||
|
||||
/**
|
||||
* createPostMessage returns a service that deals with cross
|
||||
* window communication using the postMessage API.
|
||||
* @param {string} origin
|
||||
* @param {string} scope
|
||||
* @return {Object} postMessage service
|
||||
*/
|
||||
export function createPostMessage(origin, scope = 'client') {
|
||||
// Store a reference to each listener added.
|
||||
const listeners = {};
|
||||
|
||||
// withOriginCheck wraps a given handler for a messages event with a check to
|
||||
// see if the origin matches.
|
||||
const withOriginCheck = handler => {
|
||||
return event => {
|
||||
if (get(event, 'origin') !== origin) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (get(event, 'data.scope') !== scope) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Pass the handler the event details.
|
||||
handler(event);
|
||||
};
|
||||
};
|
||||
|
||||
// withParseData will parse the data.
|
||||
const withParseData = handler => {
|
||||
return event => {
|
||||
const data = get(event, 'data.data');
|
||||
const name = get(event, 'data.name');
|
||||
if (!data || !name) {
|
||||
return;
|
||||
}
|
||||
|
||||
handler({ data, name, event });
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
post(name, data, target = window.opener) {
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Serialize the message to be sent via postMessage.
|
||||
const msg = { name, data, scope };
|
||||
|
||||
// Send the message.
|
||||
target.postMessage(msg, origin);
|
||||
},
|
||||
subscribe: (handler, target = window) => {
|
||||
// If this handler is already attached to the target, detach it.
|
||||
if (has(listeners, [target, handler])) {
|
||||
this.unsubscribeFromMessages(handler, target);
|
||||
}
|
||||
|
||||
// Wrap the listener with a origin check.
|
||||
const listener = withOriginCheck(withParseData(handler));
|
||||
|
||||
// Save a reference to the compiled listener.
|
||||
set(listeners, [target, handler], listener);
|
||||
|
||||
// Attach the listener to the target.
|
||||
target.addEventListener('message', listener);
|
||||
},
|
||||
unsubscribe: (handler, target = window) => {
|
||||
if (!has(listeners, [target, handler])) {
|
||||
return;
|
||||
}
|
||||
|
||||
const listener = get(listeners, [target, handler]);
|
||||
|
||||
// Remove the listener from the target.
|
||||
target.removeEventListener('message', listener);
|
||||
|
||||
remove(listeners, [target, handler]);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,9 +1,5 @@
|
||||
import Pym from 'pym.js';
|
||||
|
||||
const pym = new Pym.Child({ polling: 100 });
|
||||
export default pym;
|
||||
|
||||
export const link = url => e => {
|
||||
e.preventDefault();
|
||||
pym.sendMessage('navigate', url);
|
||||
};
|
||||
export default pym;
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import uuid from 'uuid/v4';
|
||||
|
||||
function getStorage(type) {
|
||||
let storage = window[type],
|
||||
x = '__storage_test__';
|
||||
let storage;
|
||||
try {
|
||||
storage = window[type];
|
||||
const x = '__storage_test__';
|
||||
storage.setItem(x, x);
|
||||
storage.removeItem(x);
|
||||
} catch (e) {
|
||||
@@ -11,6 +12,8 @@ function getStorage(type) {
|
||||
e instanceof DOMException &&
|
||||
// everything except Firefox
|
||||
(e.code === 22 ||
|
||||
// SecurityError related to having 3rd party cookies disabled.
|
||||
e.code === 18 ||
|
||||
// Firefox
|
||||
|
||||
e.code === 1014 ||
|
||||
@@ -19,14 +22,18 @@ function getStorage(type) {
|
||||
// everything except Firefox
|
||||
e.name === 'QuotaExceededError' ||
|
||||
// Firefox
|
||||
e.name === 'NS_ERROR_DOM_QUOTA_REACHED') &&
|
||||
// acknowledge QuotaExceededError only if there's something already stored
|
||||
storage.length !== 0;
|
||||
e.name === 'NS_ERROR_DOM_QUOTA_REACHED');
|
||||
if (!ignore) {
|
||||
console.warning(e); // eslint-disable-line
|
||||
return null;
|
||||
console.warn(e);
|
||||
}
|
||||
|
||||
// When third party cookies are disabled, session storage is readable/
|
||||
// writable, but localStorage is not. Try to get the sessionStorage to use.
|
||||
if (type !== 'sessionStorage') {
|
||||
return getStorage('sessionStorage');
|
||||
}
|
||||
}
|
||||
|
||||
return storage;
|
||||
}
|
||||
|
||||
|
||||
@@ -77,6 +77,30 @@ or by visiting the
|
||||
guide. This is only required while the `talk-plugin-facebook-auth` plugin is
|
||||
enabled.
|
||||
|
||||
## TALK_GOOGLE_CLIENT_ID
|
||||
|
||||
The Google OAuth2 client ID for your Google login web app. You can learn more
|
||||
about getting a Google Client ID at the
|
||||
[Google API Console](https://console.developers.google.com/apis/){:target="_blank"}.
|
||||
|
||||
You will need to enable the Google+ API in the dashboard and create credentials
|
||||
for a new OAuth client ID web application. The authorized JavaScript origin
|
||||
should be set to the Talk domain, and the authorized redirect URI should be set
|
||||
to http://<example.com>/api/v1/auth/google/callback. This is only required while
|
||||
the `talk-plugin-google-auth` plugin is enabled.
|
||||
|
||||
## TALK_GOOGLE_CLIENT_SECRET
|
||||
|
||||
The Google OAuth2 client ID for your Google login web app. You can learn more
|
||||
about getting a Google Client ID at the
|
||||
[Google API Console](https://console.developers.google.com/apis/){:target="_blank"}.
|
||||
|
||||
You will need to enable the Google+ API in the dashboard and create credentials
|
||||
for a new OAuth client ID web application. The authorized JavaScript origin
|
||||
should be set to the Talk domain, and the authorized redirect URI should be set
|
||||
to http://<example.com>/api/v1/auth/google/callback. This is only required while
|
||||
the `talk-plugin-google-auth` plugin is enabled.
|
||||
|
||||
## TALK_HELMET_CONFIGURATION
|
||||
|
||||
A JSON string representing the configuration passed to the
|
||||
@@ -538,4 +562,4 @@ This is a **Build Variable** and must be consumed during build. If using the
|
||||
[Docker-onbuild]({{ "/installation-from-docker/#onbuild" | relative_url }})
|
||||
image you can specify it with `--build-arg TALK_REPLY_COMMENTS_LOAD_DEPTH=3`.
|
||||
|
||||
Specifies the initial replies to load for a comment. (Default `3`)
|
||||
Specifies the initial replies to load for a comment. (Default `3`)
|
||||
|
||||
@@ -118,3 +118,34 @@ Configuration:
|
||||
|
||||
- `TALK_AKISMET_API_KEY` (**required**) - The Akismet API key located on your account page.
|
||||
- `TALK_AKISMET_SITE` (**required**) - The URL where you are embedding the comment stream on to provide context to Akismet. If you're hosting talk on https://talk.mynews.org/, and your news site is https://mynews.org/, then you should set this parameter to `https://mynews.org/`
|
||||
|
||||
## talk-plugin-notifications
|
||||
|
||||
Source: [plugins/talk-plugin-notifications](https://github.com/coralproject/talk/tree/master/plugins/talk-plugin-notifications){:target="_blank"}
|
||||
|
||||
Enables the Notification system for sending out enabled email notifications to
|
||||
users when they interact with Talk. By itself, this plugin will not send
|
||||
anything. You need to enable one of the `talk-plugin-notifications-category-*` plugins.
|
||||
|
||||
**Note that all `talk-plugin-notifications-*` plugins must be registered
|
||||
*before* this plugin in order to work. For example:**
|
||||
|
||||
```js
|
||||
{
|
||||
"server": [
|
||||
// ...
|
||||
"talk-plugin-notifications-category-reply",
|
||||
"talk-plugin-notifications",
|
||||
// ...
|
||||
]
|
||||
}
|
||||
```
|
||||
{:.no-copy}
|
||||
|
||||
### talk-plugin-notifications-category-reply
|
||||
{:.param}
|
||||
|
||||
Source: [plugins/talk-plugin-notifications-category-reply](https://github.com/coralproject/talk/tree/master/plugins/talk-plugin-notifications-category-reply){:target="_blank"}
|
||||
|
||||
Replies made to each user will trigger an email to be sent with the notification
|
||||
details if enabled.
|
||||
|
||||
@@ -264,7 +264,7 @@ pre {
|
||||
.toc {
|
||||
a {
|
||||
@extend .coral-link;
|
||||
border-bottom: none;
|
||||
border-bottom: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+29
-6
@@ -1,9 +1,23 @@
|
||||
const debug = require('debug')('talk:graph:connectors');
|
||||
const merge = require('lodash/merge');
|
||||
|
||||
// Config.
|
||||
const config = require('../config');
|
||||
|
||||
// Secrets.
|
||||
const secrets = require('../secrets');
|
||||
|
||||
// Errors.
|
||||
const errors = require('../errors');
|
||||
|
||||
// Graph.
|
||||
const { getBroker } = require('./subscriptions/broker');
|
||||
const { getPubsub } = require('./subscriptions/pubsub');
|
||||
const resolvers = require('./resolvers');
|
||||
const mutators = require('./mutators');
|
||||
const loaders = require('./loaders');
|
||||
const schema = require('./schema');
|
||||
|
||||
// Models.
|
||||
const Action = require('../models/action');
|
||||
const Asset = require('../models/asset');
|
||||
@@ -28,7 +42,6 @@ const Moderation = require('../services/moderation');
|
||||
const Mongoose = require('../services/mongoose');
|
||||
const Passport = require('../services/passport');
|
||||
const Plugins = require('../services/plugins');
|
||||
const Pubsub = require('../services/pubsub');
|
||||
const Redis = require('../services/redis');
|
||||
const Regex = require('../services/regex');
|
||||
const Scraper = require('../services/scraper');
|
||||
@@ -40,9 +53,11 @@ const Tokens = require('../services/tokens');
|
||||
const Users = require('../services/users');
|
||||
const Wordlist = require('../services/wordlist');
|
||||
|
||||
// Connector.
|
||||
const connectors = {
|
||||
// Connectors.
|
||||
const defaultConnectors = {
|
||||
errors,
|
||||
config,
|
||||
secrets,
|
||||
models: {
|
||||
Action,
|
||||
Asset,
|
||||
@@ -67,7 +82,6 @@ const connectors = {
|
||||
Mongoose,
|
||||
Passport,
|
||||
Plugins,
|
||||
Pubsub,
|
||||
Redis,
|
||||
Regex,
|
||||
Scraper,
|
||||
@@ -79,14 +93,23 @@ const connectors = {
|
||||
Users,
|
||||
Wordlist,
|
||||
},
|
||||
graph: {
|
||||
subscriptions: { getBroker, getPubsub },
|
||||
resolvers,
|
||||
mutators,
|
||||
loaders,
|
||||
schema,
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = Plugins.get('server', 'connectors').reduce(
|
||||
const connectors = Plugins.get('server', 'connectors').reduce(
|
||||
(defaultConnectors, { plugin, connectors: pluginConnectors }) => {
|
||||
debug(`adding plugin '${plugin.name}'`);
|
||||
|
||||
// Merge in the plugin connectors.
|
||||
return merge(defaultConnectors, pluginConnectors);
|
||||
},
|
||||
connectors
|
||||
defaultConnectors
|
||||
);
|
||||
|
||||
module.exports = connectors;
|
||||
|
||||
+58
-11
@@ -1,12 +1,13 @@
|
||||
const loaders = require('./loaders');
|
||||
const mutators = require('./mutators');
|
||||
const uuid = require('uuid');
|
||||
const merge = require('lodash/merge');
|
||||
const uuid = require('uuid/v4');
|
||||
const connectors = require('./connectors');
|
||||
|
||||
const { get, merge } = require('lodash');
|
||||
const plugins = require('../services/plugins');
|
||||
const pubsub = require('../services/pubsub');
|
||||
const { getBroker } = require('./subscriptions/broker');
|
||||
const debug = require('debug')('talk:graph:context');
|
||||
const { createLogger } = require('../services/logging');
|
||||
const { graphql } = require('graphql');
|
||||
|
||||
/**
|
||||
* Contains the array of plugins that provide context to the server, these top
|
||||
@@ -45,15 +46,16 @@ const decorateContextPlugins = (context, contextPlugins) => {
|
||||
* Stores the request context.
|
||||
*/
|
||||
class Context {
|
||||
constructor(parent) {
|
||||
constructor(ctx) {
|
||||
// Generate a new context id for the request if the parent doesn't provide
|
||||
// one.
|
||||
this.id = parent.id || uuid.v4();
|
||||
this.id = ctx.id || uuid.v4();
|
||||
|
||||
// Attach a logger or create one.
|
||||
this.log = ctx.log || createLogger('context', this.id);
|
||||
|
||||
// Load the current logged in user to `user`, otherwise this will be null.
|
||||
if (parent.user) {
|
||||
this.user = parent.user;
|
||||
}
|
||||
this.user = get(ctx, 'user');
|
||||
|
||||
// Attach the connectors.
|
||||
this.connectors = connectors;
|
||||
@@ -68,14 +70,48 @@ class Context {
|
||||
this.plugins = decorateContextPlugins(this, contextPlugins);
|
||||
|
||||
// Bind the publish/subscribe to the context.
|
||||
this.pubsub = pubsub.getClient();
|
||||
this.pubsub = getBroker();
|
||||
|
||||
// Bind the parent context.
|
||||
this.parent = parent;
|
||||
this.parent = ctx;
|
||||
}
|
||||
|
||||
/**
|
||||
* graphql will execute a graph request for the current context.
|
||||
*
|
||||
* @param {String} requestString A GraphQL language formatted string
|
||||
* representing the requested operation.
|
||||
* @param {Object} variableValues A mapping of variable name to runtime value
|
||||
* to use for all variables defined in the requestString.
|
||||
* @param {Object} rootValue The value provided as the first argument to
|
||||
* resolver functions on the top level type (e.g. the query object type).
|
||||
* @param {String} operationName The name of the operation to use if
|
||||
* requestString contains multiple possible operations. Can be omitted if
|
||||
* requestString contains only one operation.
|
||||
* @returns {Promise}
|
||||
*/
|
||||
async graphql(
|
||||
requestString,
|
||||
variableValues = {},
|
||||
rootValue = {},
|
||||
operationName = undefined
|
||||
) {
|
||||
// Perform the graph request directly using the graphql client.
|
||||
return graphql(
|
||||
// Use the connected graph schema.
|
||||
this.connectors.graph.schema,
|
||||
requestString,
|
||||
rootValue,
|
||||
// Use this, the context as the context.
|
||||
this,
|
||||
variableValues,
|
||||
operationName
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* forSystem returns a system context object that can be used for internal
|
||||
* operations.
|
||||
*/
|
||||
static forSystem() {
|
||||
const { models: { User } } = connectors;
|
||||
@@ -87,4 +123,15 @@ class Context {
|
||||
}
|
||||
}
|
||||
|
||||
// Attach the Context to the connectors.
|
||||
connectors.graph.Context = Context;
|
||||
|
||||
// Connect the connect based plugins after the server has started.
|
||||
plugins.defer('server', 'connect', ({ plugin, connect }) => {
|
||||
debug(`connecting plugin to connectors '${plugin.name}'`);
|
||||
|
||||
// Pass the connectors down to the connect plugin.
|
||||
connect(connectors);
|
||||
});
|
||||
|
||||
module.exports = Context;
|
||||
|
||||
+3
-1
@@ -2,6 +2,7 @@ const schema = require('./schema');
|
||||
const Context = require('./context');
|
||||
const { createSubscriptionManager } = require('./subscriptions');
|
||||
const { ENABLE_TRACING } = require('../config');
|
||||
const connectors = require('./connectors');
|
||||
|
||||
module.exports = {
|
||||
createGraphOptions: req => ({
|
||||
@@ -10,11 +11,12 @@ module.exports = {
|
||||
|
||||
// Load in the new context here, this will create the loaders + mutators for
|
||||
// the lifespan of this request.
|
||||
context: new Context(req),
|
||||
context: new Context(req.context),
|
||||
|
||||
// Tracing request options, needed for Apollo Engine.
|
||||
tracing: ENABLE_TRACING,
|
||||
cacheControl: ENABLE_TRACING,
|
||||
}),
|
||||
createSubscriptionManager,
|
||||
connectors,
|
||||
};
|
||||
|
||||
+11
-16
@@ -1,12 +1,7 @@
|
||||
const DataLoader = require('dataloader');
|
||||
|
||||
const util = require('./util');
|
||||
|
||||
const { SEARCH_OTHER_USERS } = require('../../perms/constants');
|
||||
|
||||
const UsersService = require('../../services/users');
|
||||
const { escapeRegExp } = require('../../services/regex');
|
||||
const UserModel = require('../../models/user');
|
||||
|
||||
const mergeState = (query, state) => {
|
||||
const { status } = state;
|
||||
@@ -51,17 +46,14 @@ const mergeState = (query, state) => {
|
||||
}
|
||||
};
|
||||
|
||||
const genUserByIDs = async (context, ids) => {
|
||||
const genUserByIDs = async (ctx, ids) => {
|
||||
if (!ids || ids.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (ids.length === 1) {
|
||||
const user = await UsersService.findById(ids[0]);
|
||||
return [user];
|
||||
}
|
||||
const { connectors: { models: { User } } } = ctx;
|
||||
|
||||
return UsersService.findByIdArray(ids).then(util.singleJoinBy(ids, 'id'));
|
||||
return User.find({ id: { $in: ids } }).then(util.singleJoinBy(ids, 'id'));
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -71,10 +63,10 @@ const genUserByIDs = async (context, ids) => {
|
||||
* @param {Object} query query terms to apply to the users query
|
||||
*/
|
||||
const getUsersByQuery = async (
|
||||
{ user },
|
||||
{ user, connectors: { models: { User } } },
|
||||
{ limit, cursor, value = '', state, action_type, sortOrder }
|
||||
) => {
|
||||
let query = UserModel.find();
|
||||
let query = User.find();
|
||||
|
||||
if (action_type || state || value.length > 0) {
|
||||
if (!user || !user.can(SEARCH_OTHER_USERS)) {
|
||||
@@ -182,8 +174,11 @@ const getUsersByQuery = async (
|
||||
* @return {Promise} resolves to the counts of the users from the
|
||||
* query
|
||||
*/
|
||||
const getCountByQuery = async ({ user }, { action_type, state }) => {
|
||||
let query = UserModel.find();
|
||||
const getCountByQuery = async (
|
||||
{ user, connectors: { models: { User } } },
|
||||
{ action_type, state }
|
||||
) => {
|
||||
const query = User.find();
|
||||
|
||||
if (action_type || state) {
|
||||
if (!user || !user.can(SEARCH_OTHER_USERS)) {
|
||||
@@ -203,7 +198,7 @@ const getCountByQuery = async ({ user }, { action_type, state }) => {
|
||||
}
|
||||
}
|
||||
|
||||
return UserModel.find(query).count();
|
||||
return query.count();
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const { SEARCH_OTHER_USERS } = require('../../perms/constants');
|
||||
const { decorateUserField } = require('./util');
|
||||
|
||||
const Action = {
|
||||
__resolveType({ action_type }) {
|
||||
@@ -11,14 +11,8 @@ const Action = {
|
||||
return undefined;
|
||||
}
|
||||
},
|
||||
|
||||
// This will load the user for the specific action. We'll limit this to the
|
||||
// admin users only or the current logged in user.
|
||||
user({ user_id }, _, { loaders: { Users }, user }) {
|
||||
if (user && (user.can(SEARCH_OTHER_USERS) || user_id === user.id)) {
|
||||
return Users.getByID.load(user_id);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
decorateUserField(Action, 'user', 'user_id');
|
||||
|
||||
module.exports = Action;
|
||||
|
||||
+10
-10
@@ -1,4 +1,6 @@
|
||||
const { decorateWithTags } = require('./util');
|
||||
const { property } = require('lodash');
|
||||
const { SEARCH_ACTIONS } = require('../../perms/constants');
|
||||
const { decorateWithTags, decorateWithPermissionCheck } = require('./util');
|
||||
|
||||
const Comment = {
|
||||
hasParent({ parent_id }) {
|
||||
@@ -29,15 +31,8 @@ const Comment = {
|
||||
|
||||
return Comments.getByQuery(query);
|
||||
},
|
||||
replyCount({ reply_count }) {
|
||||
// A simple remap from the underlying database model to the graph model.
|
||||
return reply_count;
|
||||
},
|
||||
actions({ id }, _, { user, loaders: { Actions } }) {
|
||||
if (!user || !user.can('SEARCH_ACTIONS')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
replyCount: property('reply_count'),
|
||||
actions({ id }, _, { loaders: { Actions } }) {
|
||||
return Actions.getByID.load(id);
|
||||
},
|
||||
action_summaries(comment, _, { loaders: { Actions } }) {
|
||||
@@ -65,4 +60,9 @@ const Comment = {
|
||||
// Decorate the Comment type resolver with a tags field.
|
||||
decorateWithTags(Comment);
|
||||
|
||||
// Protect direct action access.
|
||||
decorateWithPermissionCheck(Comment, {
|
||||
actions: [SEARCH_ACTIONS],
|
||||
});
|
||||
|
||||
module.exports = Comment;
|
||||
|
||||
@@ -1,18 +1,11 @@
|
||||
const FlagAction = {
|
||||
// Stored in the metadata, extract and return.
|
||||
message({ metadata: { message } }) {
|
||||
return message;
|
||||
},
|
||||
reason({ group_id }) {
|
||||
return group_id;
|
||||
},
|
||||
user({ user_id }, _, { loaders: { Users } }) {
|
||||
if (!user_id) {
|
||||
return null;
|
||||
}
|
||||
const { decorateUserField } = require('./util');
|
||||
const { property } = require('lodash');
|
||||
|
||||
return Users.getByID.load(user_id);
|
||||
},
|
||||
const FlagAction = {
|
||||
message: property('metadata.message'),
|
||||
reason: property('group_id'),
|
||||
};
|
||||
|
||||
decorateUserField(FlagAction, 'user', 'user_id');
|
||||
|
||||
module.exports = FlagAction;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
const { property } = require('lodash');
|
||||
|
||||
const FlagActionSummary = {
|
||||
reason({ group_id }) {
|
||||
return group_id;
|
||||
},
|
||||
reason: property('group_id'),
|
||||
};
|
||||
|
||||
module.exports = FlagActionSummary;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const _ = require('lodash');
|
||||
const { merge } = require('lodash');
|
||||
const debug = require('debug')('talk:graph:resolvers');
|
||||
|
||||
const Action = require('./action');
|
||||
@@ -70,7 +70,7 @@ resolvers = plugins
|
||||
.reduce((acc, { plugin, resolvers }) => {
|
||||
debug(`added plugin '${plugin.name}'`);
|
||||
|
||||
return _.merge(acc, resolvers);
|
||||
return merge(acc, resolvers);
|
||||
}, resolvers);
|
||||
|
||||
module.exports = resolvers;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
const { decorateWithPermissionCheck } = require('./util');
|
||||
const {
|
||||
SEARCH_ASSETS,
|
||||
SEARCH_OTHERS_COMMENTS,
|
||||
@@ -5,11 +6,7 @@ const {
|
||||
} = require('../../perms/constants');
|
||||
|
||||
const RootQuery = {
|
||||
assets(_, { query }, { loaders: { Assets }, user }) {
|
||||
if (user == null || !user.can(SEARCH_ASSETS)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
assets(_, { query }, { loaders: { Assets } }) {
|
||||
return Assets.getByQuery(query);
|
||||
},
|
||||
asset(_, query, { loaders: { Assets } }) {
|
||||
@@ -33,11 +30,7 @@ const RootQuery = {
|
||||
return Comments.get.load(id);
|
||||
},
|
||||
|
||||
async commentCount(_, { query }, { user, loaders: { Comments, Assets } }) {
|
||||
if (user == null || !user.can(SEARCH_OTHERS_COMMENTS)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
async commentCount(_, { query }, { loaders: { Comments, Assets } }) {
|
||||
const { asset_url, asset_id } = query;
|
||||
if (
|
||||
(!asset_id || asset_id.length === 0) &&
|
||||
@@ -53,11 +46,7 @@ const RootQuery = {
|
||||
return Comments.getCountByQuery(query);
|
||||
},
|
||||
|
||||
async userCount(_, { query }, { user, loaders: { Users } }) {
|
||||
if (user == null || !user.can(SEARCH_OTHER_USERS)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
async userCount(_, { query }, { loaders: { Users } }) {
|
||||
return Users.getCountByQuery(query);
|
||||
},
|
||||
|
||||
@@ -72,23 +61,40 @@ const RootQuery = {
|
||||
},
|
||||
|
||||
// this returns an arbitrary user
|
||||
user(_, { id }, { user, loaders: { Users } }) {
|
||||
if (user == null || !user.can(SEARCH_OTHER_USERS)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
user(_, { id }, { loaders: { Users } }) {
|
||||
return Users.getByID.load(id);
|
||||
},
|
||||
|
||||
// This endpoint is used for loading the user moderation queues (users whose username has been flagged),
|
||||
// so hide it in the event that we aren't an admin.
|
||||
users(_, { query }, { user, loaders: { Users } }) {
|
||||
if (user == null || !user.can(SEARCH_OTHER_USERS)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
users(_, { query }, { loaders: { Users } }) {
|
||||
return Users.getByQuery(query);
|
||||
},
|
||||
};
|
||||
|
||||
// Protect some query fields that are privileged.
|
||||
decorateWithPermissionCheck(RootQuery, {
|
||||
assets: [SEARCH_ASSETS],
|
||||
users: [SEARCH_OTHER_USERS],
|
||||
userCount: [SEARCH_OTHER_USERS],
|
||||
commentCount: [SEARCH_OTHERS_COMMENTS],
|
||||
});
|
||||
|
||||
// Protect the user field so only users who have permission to look up another
|
||||
// user may do so as well as a user looking up themselves.
|
||||
decorateWithPermissionCheck(
|
||||
RootQuery,
|
||||
{
|
||||
user: [SEARCH_OTHER_USERS],
|
||||
},
|
||||
(obj, { id }, { user }) => {
|
||||
if (user && user.id === id) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// We don't return false because we want to fallthrough to the permission
|
||||
// check if the custom check fails.
|
||||
}
|
||||
);
|
||||
|
||||
module.exports = RootQuery;
|
||||
|
||||
@@ -1,40 +1,23 @@
|
||||
const Subscription = {
|
||||
commentAdded(comment) {
|
||||
return comment;
|
||||
},
|
||||
commentEdited(comment) {
|
||||
return comment;
|
||||
},
|
||||
commentAccepted(comment) {
|
||||
return comment;
|
||||
},
|
||||
commentRejected(comment) {
|
||||
return comment;
|
||||
},
|
||||
commentReset(comment) {
|
||||
return comment;
|
||||
},
|
||||
commentFlagged(comment) {
|
||||
return comment;
|
||||
},
|
||||
userBanned(user) {
|
||||
return user;
|
||||
},
|
||||
userSuspended(user) {
|
||||
return user;
|
||||
},
|
||||
usernameApproved(user) {
|
||||
return user;
|
||||
},
|
||||
usernameRejected(user) {
|
||||
return user;
|
||||
},
|
||||
usernameFlagged(user) {
|
||||
return user;
|
||||
},
|
||||
usernameChanged(payload) {
|
||||
return payload;
|
||||
},
|
||||
};
|
||||
const Subscription = {};
|
||||
|
||||
// All of the subscription endpoints need to have an object to serialize when
|
||||
// pushing out via PubSub, this simply ensures that all these entries will
|
||||
// return the root object from the subscription to the PubSub framework.
|
||||
[
|
||||
'commentAdded',
|
||||
'commentEdited',
|
||||
'commentAccepted',
|
||||
'commentRejected',
|
||||
'commentReset',
|
||||
'commentFlagged',
|
||||
'userBanned',
|
||||
'userSuspended',
|
||||
'usernameApproved',
|
||||
'usernameRejected',
|
||||
'usernameFlagged',
|
||||
'usernameChanged',
|
||||
].forEach(field => {
|
||||
Subscription[field] = obj => obj;
|
||||
});
|
||||
|
||||
module.exports = Subscription;
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
const { SEARCH_OTHER_USERS } = require('../../perms/constants');
|
||||
const { decorateUserField } = require('./util');
|
||||
|
||||
const TagLink = {
|
||||
assigned_by({ assigned_by }, _, { user, loaders: { Users } }) {
|
||||
if (user && user.can(SEARCH_OTHER_USERS) && assigned_by != null) {
|
||||
return Users.getByID.load(assigned_by);
|
||||
}
|
||||
},
|
||||
};
|
||||
const TagLink = {};
|
||||
|
||||
decorateUserField(TagLink, 'assigned_by');
|
||||
|
||||
module.exports = TagLink;
|
||||
|
||||
+39
-56
@@ -1,4 +1,8 @@
|
||||
const { decorateWithTags } = require('./util');
|
||||
const {
|
||||
decorateWithTags,
|
||||
decorateWithPermissionCheck,
|
||||
checkSelfField,
|
||||
} = require('./util');
|
||||
const KarmaService = require('../../services/karma');
|
||||
const {
|
||||
SEARCH_ACTIONS,
|
||||
@@ -7,86 +11,65 @@ const {
|
||||
VIEW_USER_ROLE,
|
||||
LIST_OWN_TOKENS,
|
||||
VIEW_USER_STATUS,
|
||||
VIEW_USER_EMAIL,
|
||||
} = require('../../perms/constants');
|
||||
const { property } = require('lodash');
|
||||
|
||||
const User = {
|
||||
action_summaries(user, _, { loaders: { Actions } }) {
|
||||
return Actions.getSummariesByItem.load(user);
|
||||
},
|
||||
actions({ id }, _, { user, loaders: { Actions } }) {
|
||||
// Only return the actions if the user is not an admin.
|
||||
if (user && user.can(SEARCH_ACTIONS)) {
|
||||
return Actions.getByID.load(id);
|
||||
}
|
||||
actions({ id }, _, { loaders: { Actions } }) {
|
||||
return Actions.getByID.load(id);
|
||||
},
|
||||
comments({ id }, { query }, { loaders: { Comments }, user }) {
|
||||
// If there is no user, or there is a user, but they are requesting someone
|
||||
// else's comments, and they aren't allowed, don't return then anything!
|
||||
if (!user || (user.id !== id && !user.can(SEARCH_OTHERS_COMMENTS))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
comments({ id }, { query }, { loaders: { Comments } }) {
|
||||
// Set the author id on the query.
|
||||
query.author_id = id;
|
||||
|
||||
return Comments.getByQuery(query);
|
||||
},
|
||||
profiles({ profiles }, _, { user }) {
|
||||
// if the user is not an admin, do not return the profiles
|
||||
if (user && user.can(SEARCH_OTHER_USERS)) {
|
||||
return profiles;
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
tokens({ id, tokens }, args, { user }) {
|
||||
if (!user || (user.id !== id && !user.can(LIST_OWN_TOKENS))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return tokens;
|
||||
},
|
||||
ignoredUsers({ id }, args, { user, loaders: { Users } }) {
|
||||
// Only allow a logged in user that is either the current user or is a staff
|
||||
// member to access the ignoredUsers of a given user.
|
||||
if (!user || (user.id !== id && !user.can(SEARCH_OTHER_USERS))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
ignoredUsers({ ignoresUsers }, args, { user, loaders: { Users } }) {
|
||||
// Return nothing if there is nothing to query for.
|
||||
if (!user.ignoresUsers || user.ignoresUsers.length <= 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Users.getByID.loadMany(user.ignoresUsers);
|
||||
},
|
||||
role({ id, role }, _, { user }) {
|
||||
// If the user is not an admin, only return the current user's roles.
|
||||
if (user && (user.can(VIEW_USER_ROLE) || user.id === id)) {
|
||||
return role;
|
||||
}
|
||||
|
||||
return null;
|
||||
return Users.getByID.loadMany(ignoresUsers);
|
||||
},
|
||||
|
||||
// Extract the reliability from the user metadata if they have permission.
|
||||
reliable(user, _, { user: requestingUser }) {
|
||||
if (requestingUser && requestingUser.can(SEARCH_ACTIONS)) {
|
||||
return KarmaService.model(user);
|
||||
}
|
||||
},
|
||||
reliable: user => KarmaService.model(user),
|
||||
|
||||
state(user, args, ctx) {
|
||||
if (
|
||||
ctx.user &&
|
||||
(ctx.user.id === user.id || ctx.user.can(VIEW_USER_STATUS))
|
||||
) {
|
||||
return user;
|
||||
}
|
||||
},
|
||||
// The state requires the whole user object to make decisions.
|
||||
state: user => user,
|
||||
|
||||
// Get the first email on the user.
|
||||
email: property('firstEmail'),
|
||||
};
|
||||
|
||||
// Decorate the User type resolver with a tags field.
|
||||
decorateWithTags(User);
|
||||
|
||||
// decorate the fields on the User resolver with a permission check where the
|
||||
// current user can also get their own properties.
|
||||
decorateWithPermissionCheck(
|
||||
User,
|
||||
{
|
||||
actions: [SEARCH_ACTIONS],
|
||||
email: [VIEW_USER_EMAIL],
|
||||
state: [VIEW_USER_STATUS],
|
||||
role: [VIEW_USER_ROLE],
|
||||
ignoredUsers: [SEARCH_OTHER_USERS],
|
||||
tokens: [LIST_OWN_TOKENS],
|
||||
profiles: [SEARCH_OTHER_USERS],
|
||||
comments: [SEARCH_OTHERS_COMMENTS],
|
||||
},
|
||||
checkSelfField('id')
|
||||
);
|
||||
|
||||
// Decorate the fields on the User resolver where the current user has no impact
|
||||
// on the resolvability of a field.
|
||||
decorateWithPermissionCheck(User, { reliable: [SEARCH_ACTIONS] });
|
||||
|
||||
module.exports = User;
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
const { decorateWithPermissionCheck, checkSelfField } = require('./util');
|
||||
const { VIEW_USER_STATUS } = require('../../perms/constants');
|
||||
|
||||
const UserState = {
|
||||
status: (user, args, ctx) => {
|
||||
if (
|
||||
ctx.user &&
|
||||
(ctx.user.id === user.id || ctx.user.can(VIEW_USER_STATUS))
|
||||
) {
|
||||
return user.status;
|
||||
}
|
||||
},
|
||||
};
|
||||
const UserState = {};
|
||||
|
||||
decorateWithPermissionCheck(
|
||||
UserState,
|
||||
{ status: [VIEW_USER_STATUS] },
|
||||
checkSelfField('id')
|
||||
);
|
||||
|
||||
module.exports = UserState;
|
||||
|
||||
+164
-32
@@ -2,59 +2,167 @@ const {
|
||||
ADD_COMMENT_TAG,
|
||||
SEARCH_OTHER_USERS,
|
||||
} = require('../../perms/constants');
|
||||
const property = require('lodash/property');
|
||||
const { property, isBoolean } = require('lodash');
|
||||
|
||||
/**
|
||||
* Decorates the typeResolver with the tags field.
|
||||
* getResolver will get the resolver from the typeResolver or apply the default
|
||||
* resolver.
|
||||
*
|
||||
* @param {Object} typeResolver the type resolver
|
||||
* @param {String} field the field name of the resolver we're getting
|
||||
*/
|
||||
const decorateWithTags = typeResolver => {
|
||||
typeResolver.tags = ({ tags = [] }, _, { user }) => {
|
||||
if (user && user.can(ADD_COMMENT_TAG)) {
|
||||
return tags;
|
||||
const getResolver = (typeResolver, field) => {
|
||||
if (field in typeResolver) {
|
||||
return typeResolver[field];
|
||||
}
|
||||
|
||||
return property(field);
|
||||
};
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {Object} typeResolver the type resolver
|
||||
* @param {String} field the name of the field being wrapped
|
||||
* @param {Function} customCheck the function that can return a boolean
|
||||
* indicating the resolution status
|
||||
* @param {Function} skipFieldResolver the optional skip resolver that can be used
|
||||
* skip out from the oldFieldResolver.
|
||||
*/
|
||||
const wrapCheck = (
|
||||
typeResolver,
|
||||
field,
|
||||
customCheck,
|
||||
skipFieldResolver = getResolver(typeResolver, field)
|
||||
) => {
|
||||
// Cache the old field resolver. In the event that the check does not return
|
||||
// with a boolean, we'll use this.
|
||||
const oldFieldResolver = getResolver(typeResolver, field);
|
||||
|
||||
// Override the field resolver on the type resolver with this wrapped
|
||||
// function.
|
||||
typeResolver[field] = (obj, args, ctx, info) => {
|
||||
const decision = customCheck(obj, args, ctx, info);
|
||||
if (isBoolean(decision)) {
|
||||
if (decision) {
|
||||
// The custom check returns a boolean true, so we should execute the
|
||||
// underlying field resolver (which may just be the old resolver).
|
||||
return skipFieldResolver(obj, args, ctx, info);
|
||||
}
|
||||
|
||||
// The custom check returns a boolean false, then we should return null,
|
||||
// because the check explicity said that we weren't allowed to access that
|
||||
// field.
|
||||
return null;
|
||||
}
|
||||
|
||||
return tags.filter(t => t.tag.permissions.public);
|
||||
// The custom check yielded no decision, so we should just fall back to the
|
||||
// oldFieldResolver.
|
||||
return oldFieldResolver(obj, args, ctx, info);
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* checkPermissions checks that the current user has all the required
|
||||
* permissions.
|
||||
*
|
||||
* @param {Object} ctx graph context
|
||||
* @param {Array<String>} permissions permissions that the user must have
|
||||
*/
|
||||
const checkPermissions = (ctx, permissions) =>
|
||||
!ctx.user || !ctx.user.can(...permissions);
|
||||
|
||||
/**
|
||||
* wrapCheckPermissions will wrap a specific field with a permission check.
|
||||
*
|
||||
* @param {Object} typeResolver the type resolver
|
||||
* @param {String} field the field name of the resolver we're wrapping
|
||||
* @param {Array<String>} permissions array of permissions to check against
|
||||
* @param {Function} fieldResolver base resolver for the field
|
||||
*/
|
||||
const wrapCheckPermissions = (
|
||||
typeResolver,
|
||||
field,
|
||||
permissions,
|
||||
skipFieldResolver = getResolver(typeResolver, field)
|
||||
) =>
|
||||
wrapCheck(
|
||||
typeResolver,
|
||||
field,
|
||||
(obj, args, ctx) => !checkPermissions(ctx, permissions),
|
||||
skipFieldResolver
|
||||
);
|
||||
|
||||
/**
|
||||
* decorateWithPermissionCheck will decorate the field resolver with
|
||||
* permission checks.
|
||||
*
|
||||
* @param {Object} typeResolver the type resolver
|
||||
* @param {Object} protect the object with field -> Array<String> of permissions
|
||||
* @param {Function} customCheck a function that can return a boolean based on a
|
||||
* custom check
|
||||
*/
|
||||
const decorateWithPermissionCheck = (typeResolver, protect) => {
|
||||
const decorateWithPermissionCheck = (
|
||||
typeResolver,
|
||||
protect,
|
||||
customCheck = null
|
||||
) => {
|
||||
for (const [field, permissions] of Object.entries(protect)) {
|
||||
let fieldResolver = property(field);
|
||||
if (field in typeResolver) {
|
||||
fieldResolver = typeResolver[field];
|
||||
const baseFieldResolver = getResolver(typeResolver, field);
|
||||
wrapCheckPermissions(typeResolver, field, permissions, baseFieldResolver);
|
||||
|
||||
if (customCheck !== null) {
|
||||
wrapCheck(typeResolver, field, customCheck, baseFieldResolver);
|
||||
}
|
||||
|
||||
typeResolver[field] = (obj, args, ctx, info) => {
|
||||
if (!ctx.user || !ctx.user.can(...permissions)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return fieldResolver(obj, args, ctx, info);
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* decorateUserField will decorate the user field accesses with correct
|
||||
* permission checks.
|
||||
* checkSelf will check if the current object is the same as the current user.
|
||||
*
|
||||
* @param {String} referenceField the field for the user id to check.
|
||||
*/
|
||||
const checkSelfField = referenceField => (obj, args, ctx) => {
|
||||
if (
|
||||
ctx.user &&
|
||||
obj[referenceField] !== null &&
|
||||
ctx.user.id === obj[referenceField]
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* wrapCheckSelf wraps a typeResolver with a check for self (if the type is
|
||||
* referencing the current user).
|
||||
*
|
||||
* @param {Object} typeResolver the type resolver
|
||||
* @param {String} field the field to decorate
|
||||
* @param {String} referenceField the field to pull the user id from.
|
||||
* @param {Function} fieldResolver base resolver for the field
|
||||
*/
|
||||
const decorateUserField = (typeResolver, field) => {
|
||||
const wrapCheckSelf = (
|
||||
typeResolver,
|
||||
field,
|
||||
referenceField = field,
|
||||
fieldResolver = getResolver(typeResolver, field)
|
||||
) =>
|
||||
wrapCheck(typeResolver, field, checkSelfField(referenceField), fieldResolver);
|
||||
|
||||
/**
|
||||
* decorateUserField will decorate the user field accesses with correct
|
||||
* permission checks and will load the user.
|
||||
*
|
||||
* @param {Object} typeResolver the type resolver
|
||||
* @param {String} field the field to decorate
|
||||
* @param {String} referenceField the field to pull the user id from.
|
||||
*/
|
||||
const decorateUserField = (typeResolver, field, referenceField = field) => {
|
||||
// The default resolver for the user decorator is loading the user by id.
|
||||
let fieldResolver = (obj, args, ctx) => {
|
||||
if (!obj[field]) {
|
||||
if (!obj[referenceField]) {
|
||||
return null;
|
||||
}
|
||||
return ctx.loaders.Users.getByID.load(obj[field]);
|
||||
return ctx.loaders.Users.getByID.load(obj[referenceField]);
|
||||
};
|
||||
|
||||
// The resolver can be overridden however. This decorator will simply wrap the
|
||||
@@ -63,16 +171,39 @@ const decorateUserField = (typeResolver, field) => {
|
||||
fieldResolver = typeResolver[field];
|
||||
}
|
||||
|
||||
typeResolver[field] = (obj, args, ctx, info) => {
|
||||
if (
|
||||
!ctx.user ||
|
||||
obj[field] === null ||
|
||||
(ctx.user.id !== obj[field] && !ctx.user.can(SEARCH_OTHER_USERS))
|
||||
) {
|
||||
return null;
|
||||
// Wrap the current fieldResolver with the resolver which will return the
|
||||
// user.
|
||||
wrapCheckPermissions(
|
||||
typeResolver,
|
||||
field,
|
||||
[SEARCH_OTHER_USERS],
|
||||
fieldResolver
|
||||
);
|
||||
|
||||
// Wrap the checked resolver with a check to see if the current user is the
|
||||
// one being retrieved, in which case we should allow it.
|
||||
wrapCheckSelf(typeResolver, field, referenceField, fieldResolver);
|
||||
};
|
||||
|
||||
/**
|
||||
* decorateWithTags decorates a typeResolver with a tags getter that will
|
||||
* sanitize the tag output for users without permission to see non-public
|
||||
* tags.
|
||||
*
|
||||
* @param {Object} typeResolver base type resolver
|
||||
* @param {Function} fieldResolver the field resolver that gets the tags
|
||||
*/
|
||||
const decorateWithTags = (
|
||||
typeResolver,
|
||||
fieldResolver = getResolver(typeResolver, 'tags')
|
||||
) => {
|
||||
typeResolver.tags = async (obj, args, ctx, info) => {
|
||||
const tags = await fieldResolver(obj, args, ctx, info);
|
||||
if (checkPermissions(ctx, [ADD_COMMENT_TAG])) {
|
||||
return tags;
|
||||
}
|
||||
|
||||
return fieldResolver(obj, args, ctx, info);
|
||||
return tags.filter(t => t.tag.permissions.public);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -80,4 +211,5 @@ module.exports = {
|
||||
decorateUserField,
|
||||
decorateWithTags,
|
||||
decorateWithPermissionCheck,
|
||||
checkSelfField,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
const { EventEmitter2 } = require('eventemitter2');
|
||||
const debug = require('debug')('talk:graph:subscriptions:broker');
|
||||
const { getPubsub } = require('./pubsub');
|
||||
|
||||
/**
|
||||
* Broker acts as a pubsub client adapter. Any calls to publish will push into
|
||||
* the PubSub client and the local event emitter.
|
||||
*/
|
||||
class Broker extends EventEmitter2 {
|
||||
constructor(pubsub) {
|
||||
// Create the underlying event emitter.
|
||||
super({
|
||||
wildcard: true, // Allow wildcard listeners.
|
||||
maxListeners: 0, // Disable maximum for listeners.
|
||||
});
|
||||
|
||||
this.pubsub = pubsub;
|
||||
}
|
||||
|
||||
/**
|
||||
* Publishes the event out to the pubsub system and to the broker.
|
||||
*
|
||||
* @param {String} event the name of the event to publish
|
||||
* @param {Any} args the
|
||||
*/
|
||||
publish(event, ...args) {
|
||||
debug(`publish:${event}`);
|
||||
this.pubsub.publish(event, ...args);
|
||||
this.emit(event, ...args);
|
||||
}
|
||||
}
|
||||
|
||||
let client = null;
|
||||
const getBroker = () => {
|
||||
if (client !== null) {
|
||||
return client;
|
||||
}
|
||||
|
||||
// Create the new Broker to manage events being published out to
|
||||
// the pubsub so that we may intercept.
|
||||
client = new Broker(getPubsub());
|
||||
|
||||
debug('created');
|
||||
|
||||
return client;
|
||||
};
|
||||
|
||||
module.exports.getBroker = getBroker;
|
||||
@@ -2,18 +2,18 @@ const { SubscriptionManager } = require('graphql-subscriptions');
|
||||
const { SubscriptionServer } = require('subscriptions-transport-ws');
|
||||
const debug = require('debug')('talk:graph:subscriptions');
|
||||
|
||||
const pubsub = require('../services/pubsub');
|
||||
const schema = require('./schema');
|
||||
const Context = require('./context');
|
||||
const plugins = require('../services/plugins');
|
||||
const { getPubsub } = require('./pubsub');
|
||||
const schema = require('../schema');
|
||||
const Context = require('../context');
|
||||
const plugins = require('../../services/plugins');
|
||||
|
||||
const { deserializeUser } = require('../services/subscriptions');
|
||||
const { deserializeUser } = require('../../services/subscriptions');
|
||||
const setupFunctions = require('./setupFunctions');
|
||||
|
||||
const ms = require('ms');
|
||||
const { KEEP_ALIVE } = require('../config');
|
||||
const { KEEP_ALIVE } = require('../../config');
|
||||
|
||||
const { BASE_PATH } = require('../url');
|
||||
const { BASE_PATH } = require('../../url');
|
||||
|
||||
// Collect all the plugin hooks that should be executed onConnect and
|
||||
// onDisconnect.
|
||||
@@ -99,7 +99,7 @@ const createSubscriptionManager = server =>
|
||||
{
|
||||
subscriptionManager: new SubscriptionManager({
|
||||
schema,
|
||||
pubsub: pubsub.getClient(),
|
||||
pubsub: getPubsub(),
|
||||
setupFunctions,
|
||||
}),
|
||||
onConnect,
|
||||
@@ -0,0 +1,29 @@
|
||||
const { RedisPubSub } = require('graphql-redis-subscriptions');
|
||||
const { createClient: createRedisClient } = require('../../services/redis');
|
||||
const debug = require('debug')('talk:graph:subscriptions:pubsub');
|
||||
|
||||
/**
|
||||
* getPubsub returns the pubsub singleton for this instance.
|
||||
*/
|
||||
let pubsub = null;
|
||||
const getPubsub = () => {
|
||||
if (pubsub !== null) {
|
||||
return pubsub;
|
||||
}
|
||||
|
||||
// Create the publisher and subscriber redis clients.
|
||||
const publisher = createRedisClient();
|
||||
const subscriber = createRedisClient();
|
||||
|
||||
// Create the new PubSub client, we only need one per instance of Talk.
|
||||
pubsub = new RedisPubSub({
|
||||
publisher,
|
||||
subscriber,
|
||||
});
|
||||
|
||||
debug('created');
|
||||
|
||||
return pubsub;
|
||||
};
|
||||
|
||||
module.exports.getPubsub = getPubsub;
|
||||
@@ -11,11 +11,11 @@ const {
|
||||
SUBSCRIBE_ALL_USERNAME_APPROVED,
|
||||
SUBSCRIBE_ALL_USERNAME_FLAGGED,
|
||||
SUBSCRIBE_ALL_USERNAME_CHANGED,
|
||||
} = require('../perms/constants');
|
||||
} = require('../../perms/constants');
|
||||
|
||||
const merge = require('lodash/merge');
|
||||
const debug = require('debug')('talk:graph:setupFunctions');
|
||||
const plugins = require('../services/plugins');
|
||||
const plugins = require('../../services/plugins');
|
||||
|
||||
const setupFunctions = {
|
||||
commentAdded: (options, args, comment, context) => {
|
||||
+17
-13
@@ -63,7 +63,7 @@ type Token {
|
||||
}
|
||||
|
||||
type UserProfile {
|
||||
# the id is an identifier for the user profile (email, facebook id, etc)
|
||||
# The id is an identifier for the user profile (email, facebook id, etc)
|
||||
id: String!
|
||||
|
||||
# name of the provider attached to the authentication mode
|
||||
@@ -184,13 +184,17 @@ type User {
|
||||
# Actions completed on the parent.
|
||||
actions: [Action!]
|
||||
|
||||
# the current roles of the user.
|
||||
# The current roles of the user.
|
||||
role: USER_ROLES
|
||||
|
||||
# the current profiles of the user.
|
||||
# The current profiles of the user.
|
||||
profiles: [UserProfile]
|
||||
|
||||
# the tags on the user
|
||||
# The primary email address of the user. Only accessible to Administrators or
|
||||
# the current user.
|
||||
email: String
|
||||
|
||||
# The tags on the user.
|
||||
tags: [TagLink!]
|
||||
|
||||
# ignored users.
|
||||
@@ -421,7 +425,7 @@ input CommentCountQuery {
|
||||
# The URL that the asset is located on.
|
||||
asset_url: String
|
||||
|
||||
# the parent of the comment that we want to retrieve.
|
||||
# The parent of the comment that we want to retrieve.
|
||||
parent_id: ID
|
||||
|
||||
# comments returned will only be ones which have at least one action of this
|
||||
@@ -479,13 +483,13 @@ type Comment {
|
||||
# The body history of the comment.
|
||||
body_history: [CommentBodyHistory!]!
|
||||
|
||||
# the tags on the comment
|
||||
# The tags on the comment
|
||||
tags: [TagLink!]
|
||||
|
||||
# the user who authored the comment.
|
||||
# The user who authored the comment.
|
||||
user: User
|
||||
|
||||
# the replies that were made to the comment.
|
||||
# The replies that were made to the comment.
|
||||
replies(query: RepliesQuery = {}): CommentConnection!
|
||||
|
||||
# replyCount is the number of replies with a depth of 1. Only direct replies
|
||||
@@ -765,7 +769,7 @@ type Settings {
|
||||
infoBoxContent: String
|
||||
|
||||
# questionBoxEnable will enable the Question Box's content to be visible above
|
||||
# the comment box.
|
||||
# The comment box.
|
||||
questionBoxEnable: Boolean
|
||||
|
||||
# questionBoxContent is the content of the Question Box.
|
||||
@@ -858,7 +862,7 @@ type Asset {
|
||||
# The date that the asset was created.
|
||||
created_at: Date
|
||||
|
||||
# the tags on the asset
|
||||
# The tags on the asset
|
||||
tags: [TagLink!]
|
||||
|
||||
# The author(s) of the asset.
|
||||
@@ -1091,7 +1095,7 @@ input AssetSettingsInput {
|
||||
moderation: MODERATION_MODE
|
||||
|
||||
# questionBoxEnable will enable the Question Boxs' content to be visible above
|
||||
# the comment box.
|
||||
# The comment box.
|
||||
questionBoxEnable: Boolean
|
||||
|
||||
# questionBoxContent is the content of the Question Box.
|
||||
@@ -1167,7 +1171,7 @@ input ModifyTagInput {
|
||||
item_type: TAGGABLE_ITEM_TYPE!
|
||||
|
||||
# asset_id is used when the item_type is `COMMENTS`, the is needed to rectify
|
||||
# the settings to get the asset specific tags/settings.
|
||||
# The settings to get the asset specific tags/settings.
|
||||
asset_id: ID
|
||||
}
|
||||
|
||||
@@ -1225,7 +1229,7 @@ input UpdateSettingsInput {
|
||||
infoBoxContent: String
|
||||
|
||||
# questionBoxEnable will enable the Question Box's content to be visible above
|
||||
# the comment box.
|
||||
# The comment box.
|
||||
questionBoxEnable: Boolean
|
||||
|
||||
# questionBoxContent is the content of the Question Box.
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
const jobs = [require('./mailer')];
|
||||
|
||||
const process = () => jobs.forEach(job => job());
|
||||
|
||||
module.exports = { process };
|
||||
+147
@@ -0,0 +1,147 @@
|
||||
const { task } = require('../services/mailer');
|
||||
const nodemailer = require('nodemailer');
|
||||
const debug = require('debug')('talk:jobs:mailer');
|
||||
const Context = require('../graph/context');
|
||||
const { get } = require('lodash');
|
||||
|
||||
const {
|
||||
SMTP_HOST,
|
||||
SMTP_USERNAME,
|
||||
SMTP_PORT,
|
||||
SMTP_PASSWORD,
|
||||
SMTP_FROM_ADDRESS,
|
||||
} = require('../config');
|
||||
|
||||
// parseSMTPPort will return the port for SMTP.
|
||||
const parseSMTPPort = () => {
|
||||
if (!SMTP_PORT) {
|
||||
return 25;
|
||||
}
|
||||
|
||||
try {
|
||||
return parseInt(SMTP_PORT);
|
||||
} catch (e) {
|
||||
throw new Error('TALK_SMTP_PORT is not an integer');
|
||||
}
|
||||
};
|
||||
|
||||
// createTransport will create a new transport.
|
||||
const createTransport = () => {
|
||||
const options = {
|
||||
host: SMTP_HOST,
|
||||
};
|
||||
|
||||
if (SMTP_USERNAME && SMTP_PASSWORD) {
|
||||
options.auth = {
|
||||
user: SMTP_USERNAME,
|
||||
pass: SMTP_PASSWORD,
|
||||
};
|
||||
}
|
||||
|
||||
// Get the SMTP port.
|
||||
options.port = parseSMTPPort();
|
||||
|
||||
return nodemailer.createTransport(options);
|
||||
};
|
||||
|
||||
// sharedTransport is the transport singleton.
|
||||
let sharedTransport;
|
||||
|
||||
// getTransport will retrieve the mailer transport singleton.
|
||||
const getTransport = () => {
|
||||
if (sharedTransport) {
|
||||
return sharedTransport;
|
||||
}
|
||||
|
||||
// enabled is true when the required configuration is available. When testing
|
||||
// is enabled, we will be simulating that emails are being sent, because in a
|
||||
// production system, emails should and would be sent.
|
||||
|
||||
// If the transport details aren't available, we will return null as the
|
||||
// transport.
|
||||
if (!SMTP_HOST || !SMTP_FROM_ADDRESS) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Create the transport.
|
||||
sharedTransport = createTransport();
|
||||
|
||||
return sharedTransport;
|
||||
};
|
||||
|
||||
// getEmailAddress will retrieve the email address to send the message to from
|
||||
// the job data.
|
||||
const getEmailAddress = async ({ email, user }) => {
|
||||
// If the message has a specific email already to sent it to, just assign
|
||||
// that to the message. If the email does not have an email, and instead has
|
||||
// a user id, then we should lookup the user with the graph and get their
|
||||
// email.
|
||||
if (email) {
|
||||
return email;
|
||||
} else {
|
||||
// Get the user to send the message to.
|
||||
const ctx = Context.forSystem();
|
||||
|
||||
const { data, errors } = await ctx.graphql(
|
||||
`
|
||||
query GetUserEmail($user: ID!) {
|
||||
user(id: $user) {
|
||||
email
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ user }
|
||||
);
|
||||
if (errors) {
|
||||
throw errors;
|
||||
}
|
||||
|
||||
const email = get(data, 'user.email');
|
||||
if (!email) {
|
||||
throw errors.ErrMissingEmail;
|
||||
}
|
||||
|
||||
return email;
|
||||
}
|
||||
};
|
||||
|
||||
// processJob will handle new jobs sent via this queue.
|
||||
const processJob = transport => async ({ id, data }, done) => {
|
||||
const { message } = data;
|
||||
|
||||
// Get the email address from the job data.
|
||||
message.to = await getEmailAddress(data);
|
||||
|
||||
debug(`Starting to send mail for Job[${id}]`);
|
||||
|
||||
// Actually send the email.
|
||||
transport.sendMail(message, err => {
|
||||
if (err) {
|
||||
debug(`Failed to send mail for Job[${id}]:`, err);
|
||||
return done(err);
|
||||
}
|
||||
|
||||
debug(`Finished sending mail for Job[${id}]`);
|
||||
return done();
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Start the queue processor for the mailer job.
|
||||
*/
|
||||
module.exports = () => {
|
||||
// Get a transport.
|
||||
const transport = getTransport();
|
||||
if (transport === null) {
|
||||
console.warn(
|
||||
new Error(
|
||||
'sending email is not enabled because required configuration is not available'
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
debug(`Now processing ${task.name} jobs`);
|
||||
|
||||
return task.process(processJob(transport));
|
||||
};
|
||||
@@ -0,0 +1,76 @@
|
||||
const Asset = require('../models/asset');
|
||||
const scraper = require('../services/scraper');
|
||||
const Assets = require('../services/assets');
|
||||
const debug = require('debug')('talk:jobs:scraper');
|
||||
const metascraper = require('metascraper');
|
||||
|
||||
/**
|
||||
* Scrapes the given asset for metadata.
|
||||
*/
|
||||
async function scrape(asset) {
|
||||
return metascraper.scrapeUrl(
|
||||
asset.url,
|
||||
Object.assign({}, metascraper.RULES, {
|
||||
section: $ => $('meta[property="article:section"]').attr('content'),
|
||||
modified: $ => $('meta[property="article:modified"]').attr('content'),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates an Asset based on scraped asset metadata.
|
||||
*/
|
||||
function update(id, meta) {
|
||||
return Asset.update(
|
||||
{ id },
|
||||
{
|
||||
$set: {
|
||||
title: meta.title || '',
|
||||
description: meta.description || '',
|
||||
image: meta.image ? meta.image : '',
|
||||
author: meta.author || '',
|
||||
publication_date: meta.date || '',
|
||||
modified_date: meta.modified || '',
|
||||
section: meta.section || '',
|
||||
scraped: new Date(),
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = () => {
|
||||
debug(`Now processing ${scraper.task.name} jobs`);
|
||||
|
||||
scraper.task.process(async (job, done) => {
|
||||
debug(`Starting on Job[${job.id}] for Asset[${job.data.asset_id}]`);
|
||||
|
||||
try {
|
||||
// Find the asset, or complain that it doesn't exist.
|
||||
const asset = await Assets.findById(job.data.asset_id);
|
||||
if (!asset) {
|
||||
return done(new Error('asset not found'));
|
||||
}
|
||||
|
||||
// Scrape the metadata from the asset.
|
||||
const meta = await scrape(asset);
|
||||
|
||||
debug(
|
||||
`Scraped ${JSON.stringify(meta)} on Job[${job.id}] for Asset[${
|
||||
job.data.asset_id
|
||||
}]`
|
||||
);
|
||||
|
||||
// Assign the metadata retrieved for the asset to the db.
|
||||
await update(job.data.asset_id, meta);
|
||||
} catch (err) {
|
||||
debug(
|
||||
`Failed to scrape on Job[${job.id}] for Asset[${job.data.asset_id}]:`,
|
||||
err
|
||||
);
|
||||
return done(err);
|
||||
}
|
||||
|
||||
debug(`Finished on Job[${job.id}] for Asset[${job.data.asset_id}]`);
|
||||
done();
|
||||
});
|
||||
};
|
||||
@@ -432,6 +432,16 @@ da:
|
||||
all: "All"
|
||||
rejected: "Rejected"
|
||||
account_history: "Account History"
|
||||
account_history:
|
||||
user_banned: "User banned"
|
||||
ban_removed: "Ban removed"
|
||||
username_status: "Username {0}"
|
||||
suspended: "Suspended, {0}"
|
||||
suspension_removed: "Suspension removed"
|
||||
system: "System"
|
||||
date: "Date"
|
||||
action: "Action"
|
||||
taken_by: "Taken By"
|
||||
user_impersonating: "Denne bruger efterligner"
|
||||
user_no_comment: "Du har aldrig efterladt en kommentar. Deltag i samtalen"
|
||||
username_offensive: "Dette brugernavn er stødende"
|
||||
|
||||
@@ -431,6 +431,16 @@ de:
|
||||
all: "Alle"
|
||||
rejected: "Abgelehnte"
|
||||
account_history: "Konto-Verlauf"
|
||||
account_history:
|
||||
user_banned: "User banned"
|
||||
ban_removed: "Ban removed"
|
||||
username_status: "Username {0}"
|
||||
suspended: "Suspended, {0}"
|
||||
suspension_removed: "Suspension removed"
|
||||
system: "System"
|
||||
date: "Date"
|
||||
action: "Action"
|
||||
taken_by: "Taken By"
|
||||
user_impersonating: "Gibt sich für jemand anderen aus"
|
||||
user_no_comment: "Sie haben noch keinen Kommentar abgegeben. Teilen Sie Ihre Meinung mit uns!"
|
||||
username_offensive: "Dieser Nutzername ist unangemessen"
|
||||
|
||||
+13
-1
@@ -396,6 +396,8 @@ en:
|
||||
one_hour: "1 hour"
|
||||
hours: "{0} hours"
|
||||
days: "{0} days"
|
||||
hour: "{0} hours"
|
||||
day: "{0} days"
|
||||
cancel: "Cancel"
|
||||
suspend_user: "Suspend User"
|
||||
email_message_suspend: "Dear {0},\n\nIn accordance with {1}’s community guidelines, your account has been temporarily suspended. During the suspension, you will be unable to comment, flag or engage with fellow commenters. Please rejoin the conversation {2}."
|
||||
@@ -434,6 +436,16 @@ en:
|
||||
all: "All"
|
||||
rejected: "Rejected"
|
||||
account_history: "Account History"
|
||||
account_history:
|
||||
user_banned: "User banned"
|
||||
ban_removed: "Ban removed"
|
||||
username_status: "Username {0}"
|
||||
suspended: "Suspended, {0}"
|
||||
suspension_removed: "Suspension removed"
|
||||
system: "System"
|
||||
date: "Date"
|
||||
action: "Action"
|
||||
taken_by: "Taken By"
|
||||
user_impersonating: "This user is impersonating"
|
||||
user_no_comment: "You've never left a comment. Join the conversation!"
|
||||
username_offensive: "This username is offensive"
|
||||
@@ -462,4 +474,4 @@ en:
|
||||
close: "Close this Installer"
|
||||
admin_sidebar:
|
||||
view_options: "View Options"
|
||||
sort_comments: "Sort Comments"
|
||||
sort_comments: "Sort Comments"
|
||||
@@ -433,6 +433,16 @@ es:
|
||||
all: "All"
|
||||
rejected: "Rejected"
|
||||
account_history: "Account History"
|
||||
account_history:
|
||||
user_banned: "User banned"
|
||||
ban_removed: "Ban removed"
|
||||
username_status: "Username {0}"
|
||||
suspended: "Suspended, {0}"
|
||||
suspension_removed: "Suspension removed"
|
||||
system: "System"
|
||||
date: "Date"
|
||||
action: "Action"
|
||||
taken_by: "Taken By"
|
||||
user_impersonating: "Este usuario suplanta a alguien"
|
||||
user_no_comment: "No has dejado aún ningún comentario. ¡Únete a la conversación!"
|
||||
username_offensive: "Este nombre de usuario es ofensivo"
|
||||
|
||||
@@ -433,6 +433,16 @@ fr:
|
||||
all: "All"
|
||||
rejected: "Rejected"
|
||||
account_history: "Account History"
|
||||
account_history:
|
||||
user_banned: "User banned"
|
||||
ban_removed: "Ban removed"
|
||||
username_status: "Username {0}"
|
||||
suspended: "Suspended, {0}"
|
||||
suspension_removed: "Suspension removed"
|
||||
system: "System"
|
||||
date: "Date"
|
||||
action: "Action"
|
||||
taken_by: "Taken By"
|
||||
user_impersonating: "Cet utilisateur se fait passer pour quelqu'un d'autre"
|
||||
user_no_comment: "Vous n'avez jamais laissé de commentaire. Rejoignez la conversation !"
|
||||
username_offensive: "Ce nom d'utilisateur est offensant"
|
||||
|
||||
@@ -432,6 +432,16 @@ nl_NL:
|
||||
all: "Alle"
|
||||
rejected: "Afgewezen"
|
||||
account_history: "Accountgeschiedenis"
|
||||
account_history:
|
||||
user_banned: "User banned"
|
||||
ban_removed: "Ban removed"
|
||||
username_status: "Username {0}"
|
||||
suspended: "Suspended, {0}"
|
||||
suspension_removed: "Suspension removed"
|
||||
system: "System"
|
||||
date: "Date"
|
||||
action: "Action"
|
||||
taken_by: "Taken By"
|
||||
user_impersonating: "Deze gebruiker imiteert"
|
||||
user_no_comment: "Je hebt nog niet eerder gereageerd. Laat je mening horen!"
|
||||
username_offensive: "Dit is een aanstootgevende gebruikersnaam."
|
||||
|
||||
@@ -431,6 +431,16 @@ pt_BR:
|
||||
all: "All"
|
||||
rejected: "Rejected"
|
||||
account_history: "Account History"
|
||||
account_history:
|
||||
user_banned: "User banned"
|
||||
ban_removed: "Ban removed"
|
||||
username_status: "Username {0}"
|
||||
suspended: "Suspended, {0}"
|
||||
suspension_removed: "Suspension removed"
|
||||
system: "System"
|
||||
date: "Date"
|
||||
action: "Action"
|
||||
taken_by: "Taken By"
|
||||
user_impersonating: "Este usuário está representando"
|
||||
user_no_comment: "Você nunca deixou um comentário. Participe da conversa!"
|
||||
username_offensive: "Esse nome de usuário é ofensivo"
|
||||
|
||||
@@ -433,6 +433,16 @@ zh_CN:
|
||||
all: "All"
|
||||
rejected: "Rejected"
|
||||
account_history: "Account History"
|
||||
account_history:
|
||||
user_banned: "User banned"
|
||||
ban_removed: "Ban removed"
|
||||
username_status: "Username {0}"
|
||||
suspended: "Suspended, {0}"
|
||||
suspension_removed: "Suspension removed"
|
||||
system: "System"
|
||||
date: "Date"
|
||||
action: "Action"
|
||||
taken_by: "Taken By"
|
||||
user_impersonating: "冒名用户"
|
||||
user_no_comment: "您未曾发表评论。现在就来加入对话吧!"
|
||||
username_offensive: "用户名有冒犯性"
|
||||
|
||||
@@ -433,6 +433,16 @@ zh_TW:
|
||||
all: "All"
|
||||
rejected: "Rejected"
|
||||
account_history: "Account History"
|
||||
account_history:
|
||||
user_banned: "User banned"
|
||||
ban_removed: "Ban removed"
|
||||
username_status: "Username {0}"
|
||||
suspended: "Suspended, {0}"
|
||||
suspension_removed: "Suspension removed"
|
||||
system: "System"
|
||||
date: "Date"
|
||||
action: "Action"
|
||||
taken_by: "Taken By"
|
||||
user_impersonating: "此用戶正在冒充"
|
||||
user_no_comment: "您尚未評論過。加入對話吧!"
|
||||
username_offensive: "這個用戶名有冒犯性"
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
const Context = require('../graph/context');
|
||||
|
||||
// Attach a new context to the request.
|
||||
module.exports = (req, res, next) => {
|
||||
req.context = new Context(req);
|
||||
|
||||
next();
|
||||
};
|
||||
@@ -1,6 +1,12 @@
|
||||
const SettingsService = require('../services/settings');
|
||||
|
||||
const { BASE_URL, BASE_PATH, MOUNT_PATH, STATIC_URL } = require('../url');
|
||||
const {
|
||||
BASE_URL,
|
||||
BASE_PATH,
|
||||
MOUNT_PATH,
|
||||
STATIC_URL,
|
||||
STATIC_ORIGIN,
|
||||
} = require('../url');
|
||||
|
||||
const { RECAPTCHA_PUBLIC, WEBSOCKET_LIVE_URI } = require('../config');
|
||||
|
||||
@@ -15,6 +21,7 @@ const TEMPLATE_LOCALS = {
|
||||
TALK_RECAPTCHA_PUBLIC: RECAPTCHA_PUBLIC,
|
||||
LIVE_URI: WEBSOCKET_LIVE_URI,
|
||||
STATIC_URL,
|
||||
STATIC_ORIGIN,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -99,6 +99,12 @@ const SettingSchema = new Schema(
|
||||
default: 30 * 1000,
|
||||
},
|
||||
tags: [TagSchema],
|
||||
|
||||
// Additional metadata to let plugins write settings.
|
||||
metadata: {
|
||||
default: {},
|
||||
type: Object,
|
||||
},
|
||||
},
|
||||
{
|
||||
timestamps: {
|
||||
|
||||
+14
-14
@@ -5,7 +5,6 @@
|
||||
"main": "app.js",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"postinstall": "./bin/cli plugins reconcile --skip-remote",
|
||||
"generate-introspection": "WEBPACK=TRUE NODE_ENV=test ./scripts/generateIntrospectionResult.js",
|
||||
"clean": "rm -rf dist client/coral-framework/graphql/introspection.json",
|
||||
"watch": "npm-run-all clean generate-introspection --parallel watch:*",
|
||||
@@ -16,7 +15,7 @@
|
||||
"prebuild": "npm-run-all clean generate-introspection",
|
||||
"build": "NODE_ENV=production parallel-webpack --bail",
|
||||
"lint:yaml": "yamllint locales/*.yml",
|
||||
"lint:js": "eslint bin/* .",
|
||||
"lint:js": "eslint bin/cli* .",
|
||||
"lint": "npm-run-all lint:*",
|
||||
"plugins:reconcile": "./bin/cli plugins reconcile",
|
||||
"test": "npm-run-all test:client test:server",
|
||||
@@ -25,13 +24,18 @@
|
||||
"test:client:watch": "TEST_MODE=unit NODE_ENV=test jest --watch",
|
||||
"e2e": "./scripts/e2e.js",
|
||||
"e2e:ci": "./scripts/e2e-ci.sh",
|
||||
"heroku-postbuild": "npm-run-all plugins:reconcile build"
|
||||
"heroku-postbuild": "npm-run-all plugins:reconcile build",
|
||||
"precommit": "npm-run-all lint-staged",
|
||||
"lint-staged": "lint-staged"
|
||||
},
|
||||
"talk": {
|
||||
"migration": {
|
||||
"minVersion": 1516920160
|
||||
}
|
||||
},
|
||||
"workspaces": [
|
||||
"plugins/*"
|
||||
],
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/coralproject/talk.git"
|
||||
@@ -74,6 +78,7 @@
|
||||
"bcryptjs": "^2.4.3",
|
||||
"bowser": "^1.7.2",
|
||||
"brotli-webpack-plugin": "^0.5.0",
|
||||
"bunyan": "^1.8.12",
|
||||
"cli-table": "^0.3.1",
|
||||
"clipboard": "^1.7.1",
|
||||
"colors": "^1.1.2",
|
||||
@@ -112,13 +117,14 @@
|
||||
"hammerjs": "^2.0.8",
|
||||
"helmet": "3.8.2",
|
||||
"history": "^3.0.0",
|
||||
"hjson": "^3.1.1",
|
||||
"hjson-loader": "^1.0.0",
|
||||
"immutability-helper": "^2.2.0",
|
||||
"imports-loader": "^0.7.1",
|
||||
"inquirer": "^3.2.2",
|
||||
"inquirer-autocomplete-prompt": "^0.12.1",
|
||||
"ioredis": "3.1.4",
|
||||
"joi": "^13.0.0",
|
||||
"json-loader": "^0.5.7",
|
||||
"jsonwebtoken": "^8.0.0",
|
||||
"jwt-decode": "^2.2.0",
|
||||
"keymaster": "^1.6.2",
|
||||
@@ -132,7 +138,7 @@
|
||||
"minimist": "^1.2.0",
|
||||
"moment": "^2.18.1",
|
||||
"mongoose": "^4.12.3",
|
||||
"morgan": "1.9.0",
|
||||
"morgan": "^1.9.0",
|
||||
"ms": "^2.0.0",
|
||||
"murmurhash-js": "^1.0.0",
|
||||
"node-emoji": "^1.8.1",
|
||||
@@ -192,6 +198,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@coralproject/eslint-config-talk": "^0.1.0",
|
||||
"@easyops/git-exec-and-restage": "^1.0.4",
|
||||
"babel-jest": "^21.2.0",
|
||||
"babel-plugin-dynamic-import-node": "^1.1.0",
|
||||
"babel-plugin-transform-es2015-modules-commonjs": "^6.26.0",
|
||||
@@ -204,14 +211,15 @@
|
||||
"enzyme-adapter-react-15": "^1.0.0",
|
||||
"eslint": "^4.5.0",
|
||||
"eslint-plugin-mocha": "^4.11.0",
|
||||
"husky": "^0.14.3",
|
||||
"identity-obj-proxy": "^3.0.0",
|
||||
"ip": "^1.1.5",
|
||||
"jest": "^21.2.1",
|
||||
"lint-staged": "^7.0.0",
|
||||
"mocha": "^3.1.2",
|
||||
"mocha-junit-reporter": "^1.12.1",
|
||||
"nightwatch": "^0.9.16",
|
||||
"nodemon": "^1.11.0",
|
||||
"pre-commit": "^1.2.2",
|
||||
"selenium-standalone": "^6.11.0",
|
||||
"sinon": "^3.2.1",
|
||||
"sinon-chai": "^2.13.0",
|
||||
@@ -219,13 +227,5 @@
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"pre-commit": {
|
||||
"silent": false,
|
||||
"run": [
|
||||
"lint",
|
||||
"test:client",
|
||||
"test:server"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,4 +9,5 @@ module.exports = {
|
||||
VIEW_PROTECTED_SETTINGS: 'VIEW_PROTECTED_SETTINGS',
|
||||
LIST_OWN_TOKENS: 'LIST_OWN_TOKENS',
|
||||
VIEW_USER_ROLE: 'VIEW_USER_ROLE',
|
||||
VIEW_USER_EMAIL: 'VIEW_USER_EMAIL',
|
||||
};
|
||||
|
||||
@@ -8,11 +8,11 @@ module.exports = (user, perm) => {
|
||||
case types.SEARCH_ACTIONS:
|
||||
case types.SEARCH_NON_NULL_OR_ACCEPTED_COMMENTS:
|
||||
case types.SEARCH_OTHERS_COMMENTS:
|
||||
return check(user, ['ADMIN', 'MODERATOR']);
|
||||
case types.SEARCH_COMMENT_STATUS_HISTORY:
|
||||
case types.VIEW_USER_STATUS:
|
||||
case types.VIEW_PROTECTED_SETTINGS:
|
||||
case types.VIEW_USER_ROLE:
|
||||
case types.VIEW_USER_EMAIL:
|
||||
return check(user, ['ADMIN', 'MODERATOR']);
|
||||
case types.LIST_OWN_TOKENS:
|
||||
return check(user, ['ADMIN']);
|
||||
|
||||
@@ -6,6 +6,7 @@ export {
|
||||
withEmit,
|
||||
excludeIf,
|
||||
withFragments,
|
||||
withMutation,
|
||||
withForgotPassword,
|
||||
withSignIn,
|
||||
withSignUp,
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { default as Action } from 'coral-framework/lib/action';
|
||||
@@ -21,6 +21,7 @@
|
||||
"talk-plugin-sort-most-respected",
|
||||
"talk-plugin-sort-newest",
|
||||
"talk-plugin-sort-oldest",
|
||||
"talk-plugin-viewing-options"
|
||||
"talk-plugin-viewing-options",
|
||||
"talk-plugin-profile-settings"
|
||||
]
|
||||
}
|
||||
|
||||
+35
-3
@@ -4,6 +4,7 @@ const resolve = require('resolve');
|
||||
const debug = require('debug')('talk:plugins');
|
||||
const Joi = require('joi');
|
||||
const amp = require('app-module-path');
|
||||
const hjson = require('hjson');
|
||||
const pkg = require('./package.json');
|
||||
|
||||
const PLUGINS_JSON = process.env.TALK_PLUGINS_JSON;
|
||||
@@ -33,7 +34,9 @@ try {
|
||||
pluginsPath = defaultPlugins;
|
||||
}
|
||||
|
||||
plugins = require(pluginsPath);
|
||||
// Load/parse the plugin content using hjson.
|
||||
const pluginContent = fs.readFileSync(pluginsPath, 'utf8');
|
||||
plugins = hjson.parse(pluginContent);
|
||||
} catch (err) {
|
||||
if (err.code === 'ENOENT') {
|
||||
console.error(
|
||||
@@ -73,6 +76,7 @@ const hookSchemas = {
|
||||
onConnect: Joi.func(),
|
||||
onDisconnect: Joi.func(),
|
||||
}),
|
||||
connect: Joi.func().maxArity(1),
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -293,14 +297,42 @@ class PluginManager {
|
||||
plugins[section]
|
||||
);
|
||||
}
|
||||
|
||||
this.deferredHooks = [];
|
||||
this.ranDeferredHooks = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function which combines the Plugins.section and PluginSection.hook
|
||||
* calls.
|
||||
*/
|
||||
get(section, hook) {
|
||||
return this.section(section).hook(hook);
|
||||
get(sectionName, hookName) {
|
||||
return this.section(sectionName).hook(hookName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function which combines the Plugins.section and PluginSection.hook
|
||||
* calls and runs them when the `runDeferred` is called.
|
||||
*/
|
||||
defer(sectionName, hookName, callback) {
|
||||
const plugins = this.section(sectionName).hook(hookName);
|
||||
|
||||
// If we've already ran the callbacks, then we should run it immediately.
|
||||
if (this.ranDeferredHooks) {
|
||||
plugins.forEach(callback);
|
||||
} else {
|
||||
this.deferredHooks.push({ plugins, callback });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls all deferred hooks.
|
||||
*/
|
||||
runDeferred() {
|
||||
this.deferredHooks.forEach(({ plugins, callback }) =>
|
||||
plugins.forEach(callback)
|
||||
);
|
||||
this.ranDeferredHooks = true;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,141 +0,0 @@
|
||||
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
|
||||
# yarn lockfile v1
|
||||
|
||||
|
||||
akismet-api@^4.0.1:
|
||||
version "4.0.1"
|
||||
resolved "https://registry.yarnpkg.com/akismet-api/-/akismet-api-4.0.1.tgz#1c771442f09316847132aa16171bb4fb708b6519"
|
||||
dependencies:
|
||||
bluebird "^3.1.1"
|
||||
superagent "^3.8.0"
|
||||
|
||||
asynckit@^0.4.0:
|
||||
version "0.4.0"
|
||||
resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79"
|
||||
|
||||
bluebird@^3.1.1:
|
||||
version "3.5.1"
|
||||
resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.5.1.tgz#d9551f9de98f1fcda1e683d17ee91a0602ee2eb9"
|
||||
|
||||
combined-stream@^1.0.5:
|
||||
version "1.0.5"
|
||||
resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.5.tgz#938370a57b4a51dea2c77c15d5c5fdf895164009"
|
||||
dependencies:
|
||||
delayed-stream "~1.0.0"
|
||||
|
||||
component-emitter@^1.2.0:
|
||||
version "1.2.1"
|
||||
resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.2.1.tgz#137918d6d78283f7df7a6b7c5a63e140e69425e6"
|
||||
|
||||
cookiejar@^2.1.0:
|
||||
version "2.1.1"
|
||||
resolved "https://registry.yarnpkg.com/cookiejar/-/cookiejar-2.1.1.tgz#41ad57b1b555951ec171412a81942b1e8200d34a"
|
||||
|
||||
core-util-is@~1.0.0:
|
||||
version "1.0.2"
|
||||
resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7"
|
||||
|
||||
debug@^3.1.0:
|
||||
version "3.1.0"
|
||||
resolved "https://registry.yarnpkg.com/debug/-/debug-3.1.0.tgz#5bb5a0672628b64149566ba16819e61518c67261"
|
||||
dependencies:
|
||||
ms "2.0.0"
|
||||
|
||||
delayed-stream@~1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619"
|
||||
|
||||
extend@^3.0.0:
|
||||
version "3.0.1"
|
||||
resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.1.tgz#a755ea7bc1adfcc5a31ce7e762dbaadc5e636444"
|
||||
|
||||
form-data@^2.3.1:
|
||||
version "2.3.1"
|
||||
resolved "https://registry.yarnpkg.com/form-data/-/form-data-2.3.1.tgz#6fb94fbd71885306d73d15cc497fe4cc4ecd44bf"
|
||||
dependencies:
|
||||
asynckit "^0.4.0"
|
||||
combined-stream "^1.0.5"
|
||||
mime-types "^2.1.12"
|
||||
|
||||
formidable@^1.1.1:
|
||||
version "1.1.1"
|
||||
resolved "https://registry.yarnpkg.com/formidable/-/formidable-1.1.1.tgz#96b8886f7c3c3508b932d6bd70c4d3a88f35f1a9"
|
||||
|
||||
inherits@~2.0.3:
|
||||
version "2.0.3"
|
||||
resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de"
|
||||
|
||||
isarray@~1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11"
|
||||
|
||||
methods@^1.1.1:
|
||||
version "1.1.2"
|
||||
resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee"
|
||||
|
||||
mime-db@~1.30.0:
|
||||
version "1.30.0"
|
||||
resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.30.0.tgz#74c643da2dd9d6a45399963465b26d5ca7d71f01"
|
||||
|
||||
mime-types@^2.1.12:
|
||||
version "2.1.17"
|
||||
resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.17.tgz#09d7a393f03e995a79f8af857b70a9e0ab16557a"
|
||||
dependencies:
|
||||
mime-db "~1.30.0"
|
||||
|
||||
mime@^1.4.1:
|
||||
version "1.6.0"
|
||||
resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1"
|
||||
|
||||
ms@2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8"
|
||||
|
||||
process-nextick-args@~1.0.6:
|
||||
version "1.0.7"
|
||||
resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-1.0.7.tgz#150e20b756590ad3f91093f25a4f2ad8bff30ba3"
|
||||
|
||||
qs@^6.5.1:
|
||||
version "6.5.1"
|
||||
resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.1.tgz#349cdf6eef89ec45c12d7d5eb3fc0c870343a6d8"
|
||||
|
||||
readable-stream@^2.0.5:
|
||||
version "2.3.3"
|
||||
resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.3.tgz#368f2512d79f9d46fdfc71349ae7878bbc1eb95c"
|
||||
dependencies:
|
||||
core-util-is "~1.0.0"
|
||||
inherits "~2.0.3"
|
||||
isarray "~1.0.0"
|
||||
process-nextick-args "~1.0.6"
|
||||
safe-buffer "~5.1.1"
|
||||
string_decoder "~1.0.3"
|
||||
util-deprecate "~1.0.1"
|
||||
|
||||
safe-buffer@~5.1.0, safe-buffer@~5.1.1:
|
||||
version "5.1.1"
|
||||
resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.1.tgz#893312af69b2123def71f57889001671eeb2c853"
|
||||
|
||||
string_decoder@~1.0.3:
|
||||
version "1.0.3"
|
||||
resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.0.3.tgz#0fc67d7c141825de94282dd536bec6b9bce860ab"
|
||||
dependencies:
|
||||
safe-buffer "~5.1.0"
|
||||
|
||||
superagent@^3.8.0:
|
||||
version "3.8.2"
|
||||
resolved "https://registry.yarnpkg.com/superagent/-/superagent-3.8.2.tgz#e4a11b9d047f7d3efeb3bbe536d9ec0021d16403"
|
||||
dependencies:
|
||||
component-emitter "^1.2.0"
|
||||
cookiejar "^2.1.0"
|
||||
debug "^3.1.0"
|
||||
extend "^3.0.0"
|
||||
form-data "^2.3.1"
|
||||
formidable "^1.1.1"
|
||||
methods "^1.1.1"
|
||||
mime "^1.4.1"
|
||||
qs "^6.5.1"
|
||||
readable-stream "^2.0.5"
|
||||
|
||||
util-deprecate@~1.0.1:
|
||||
version "1.0.2"
|
||||
resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"
|
||||
@@ -4,10 +4,6 @@ import Main from '../components/Main';
|
||||
import { connect } from 'plugin-api/beta/client/hocs';
|
||||
import { bindActionCreators } from 'redux';
|
||||
import { setView } from '../actions';
|
||||
import {
|
||||
setAuthToken,
|
||||
handleSuccessfulLogin,
|
||||
} from 'plugin-api/beta/client/actions/auth';
|
||||
import * as views from '../enums/views';
|
||||
|
||||
class MainContainer extends React.Component {
|
||||
@@ -24,7 +20,6 @@ class MainContainer extends React.Component {
|
||||
|
||||
componentDidMount() {
|
||||
this.resizeHeight();
|
||||
this.listenToStorageChanges();
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
@@ -33,40 +28,6 @@ class MainContainer extends React.Component {
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.unlisten();
|
||||
}
|
||||
|
||||
listenToStorageChanges() {
|
||||
window.addEventListener('storage', this.handleAuth);
|
||||
}
|
||||
|
||||
unlisten() {
|
||||
window.removeEventListener('storage', this.handleAuth);
|
||||
}
|
||||
|
||||
// External logins store auth data into `auth`, we use it to detect
|
||||
// a successful sign in.
|
||||
handleAuth = e => {
|
||||
if (e.key === 'auth') {
|
||||
const { err, data } = JSON.parse(e.newValue);
|
||||
if (err) {
|
||||
console.error(err);
|
||||
} else if (data && data.token) {
|
||||
if (data.user) {
|
||||
this.props.handleSuccessfulLogin(data.user, data.token);
|
||||
} else {
|
||||
this.props.setAuthToken(data.token);
|
||||
}
|
||||
this.unlisten();
|
||||
localStorage.removeItem('auth');
|
||||
window.close();
|
||||
} else {
|
||||
console.error('auth was set, but did not contain a token');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
return <Main onResetView={this.resetView} view={this.props.view} />;
|
||||
}
|
||||
@@ -75,8 +36,6 @@ class MainContainer extends React.Component {
|
||||
MainContainer.propTypes = {
|
||||
view: PropTypes.string.isRequired,
|
||||
setView: PropTypes.func.isRequired,
|
||||
handleSuccessfulLogin: PropTypes.func.isRequired,
|
||||
setAuthToken: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
const mapStateToProps = ({ talkPluginAuth: state }) => ({
|
||||
@@ -87,8 +46,6 @@ const mapDispatchToProps = dispatch =>
|
||||
bindActionCreators(
|
||||
{
|
||||
setView,
|
||||
handleSuccessfulLogin,
|
||||
setAuthToken,
|
||||
},
|
||||
dispatch
|
||||
);
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
|
||||
# yarn lockfile v1
|
||||
|
||||
|
||||
asap@~2.0.3:
|
||||
version "2.0.5"
|
||||
resolved "https://registry.yarnpkg.com/asap/-/asap-2.0.5.tgz#522765b50c3510490e52d7dcfe085ef9ba96958f"
|
||||
|
||||
core-js@^1.0.0:
|
||||
version "1.2.7"
|
||||
resolved "https://registry.yarnpkg.com/core-js/-/core-js-1.2.7.tgz#652294c14651db28fa93bd2d5ff2983a4f08c636"
|
||||
|
||||
encoding@^0.1.11:
|
||||
version "0.1.12"
|
||||
resolved "https://registry.yarnpkg.com/encoding/-/encoding-0.1.12.tgz#538b66f3ee62cd1ab51ec323829d1f9480c74beb"
|
||||
dependencies:
|
||||
iconv-lite "~0.4.13"
|
||||
|
||||
fbjs@^0.8.9:
|
||||
version "0.8.12"
|
||||
resolved "https://registry.yarnpkg.com/fbjs/-/fbjs-0.8.12.tgz#10b5d92f76d45575fd63a217d4ea02bea2f8ed04"
|
||||
dependencies:
|
||||
core-js "^1.0.0"
|
||||
isomorphic-fetch "^2.1.1"
|
||||
loose-envify "^1.0.0"
|
||||
object-assign "^4.1.0"
|
||||
promise "^7.1.1"
|
||||
setimmediate "^1.0.5"
|
||||
ua-parser-js "^0.7.9"
|
||||
|
||||
iconv-lite@~0.4.13:
|
||||
version "0.4.18"
|
||||
resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.18.tgz#23d8656b16aae6742ac29732ea8f0336a4789cf2"
|
||||
|
||||
is-stream@^1.0.1:
|
||||
version "1.1.0"
|
||||
resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-1.1.0.tgz#12d4a3dd4e68e0b79ceb8dbc84173ae80d91ca44"
|
||||
|
||||
isomorphic-fetch@^2.1.1:
|
||||
version "2.2.1"
|
||||
resolved "https://registry.yarnpkg.com/isomorphic-fetch/-/isomorphic-fetch-2.2.1.tgz#611ae1acf14f5e81f729507472819fe9733558a9"
|
||||
dependencies:
|
||||
node-fetch "^1.0.1"
|
||||
whatwg-fetch ">=0.10.0"
|
||||
|
||||
js-tokens@^3.0.0:
|
||||
version "3.0.1"
|
||||
resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-3.0.1.tgz#08e9f132484a2c45a30907e9dc4d5567b7f114d7"
|
||||
|
||||
linkify-it@^1.2.0:
|
||||
version "1.2.4"
|
||||
resolved "https://registry.yarnpkg.com/linkify-it/-/linkify-it-1.2.4.tgz#0773526c317c8fd13bd534ee1d180ff88abf881a"
|
||||
dependencies:
|
||||
uc.micro "^1.0.1"
|
||||
|
||||
loose-envify@^1.0.0, loose-envify@^1.3.1:
|
||||
version "1.3.1"
|
||||
resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.3.1.tgz#d1a8ad33fa9ce0e713d65fdd0ac8b748d478c848"
|
||||
dependencies:
|
||||
js-tokens "^3.0.0"
|
||||
|
||||
node-fetch@^1.0.1:
|
||||
version "1.7.1"
|
||||
resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-1.7.1.tgz#899cb3d0a3c92f952c47f1b876f4c8aeabd400d5"
|
||||
dependencies:
|
||||
encoding "^0.1.11"
|
||||
is-stream "^1.0.1"
|
||||
|
||||
object-assign@^4.1.0:
|
||||
version "4.1.1"
|
||||
resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863"
|
||||
|
||||
promise@^7.1.1:
|
||||
version "7.1.1"
|
||||
resolved "https://registry.yarnpkg.com/promise/-/promise-7.1.1.tgz#489654c692616b8aa55b0724fa809bb7db49c5bf"
|
||||
dependencies:
|
||||
asap "~2.0.3"
|
||||
|
||||
prop-types@^15.5.8:
|
||||
version "15.5.10"
|
||||
resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.5.10.tgz#2797dfc3126182e3a95e3dfbb2e893ddd7456154"
|
||||
dependencies:
|
||||
fbjs "^0.8.9"
|
||||
loose-envify "^1.3.1"
|
||||
|
||||
react-linkify@^0.2.1:
|
||||
version "0.2.1"
|
||||
resolved "https://registry.yarnpkg.com/react-linkify/-/react-linkify-0.2.1.tgz#b28d3f9544539a622fec8d42b4800eb9d23bf981"
|
||||
dependencies:
|
||||
linkify-it "^1.2.0"
|
||||
prop-types "^15.5.8"
|
||||
tlds "^1.57.0"
|
||||
|
||||
setimmediate@^1.0.5:
|
||||
version "1.0.5"
|
||||
resolved "https://registry.yarnpkg.com/setimmediate/-/setimmediate-1.0.5.tgz#290cbb232e306942d7d7ea9b83732ab7856f8285"
|
||||
|
||||
tlds@^1.57.0:
|
||||
version "1.189.0"
|
||||
resolved "https://registry.yarnpkg.com/tlds/-/tlds-1.189.0.tgz#b8cb46ea76dc2f4a01d45b8d907bf19a66e9f729"
|
||||
|
||||
ua-parser-js@^0.7.9:
|
||||
version "0.7.12"
|
||||
resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.12.tgz#04c81a99bdd5dc52263ea29d24c6bf8d4818a4bb"
|
||||
|
||||
uc.micro@^1.0.1:
|
||||
version "1.0.3"
|
||||
resolved "https://registry.yarnpkg.com/uc.micro/-/uc.micro-1.0.3.tgz#7ed50d5e0f9a9fb0a573379259f2a77458d50192"
|
||||
|
||||
whatwg-fetch@>=0.10.0:
|
||||
version "2.0.3"
|
||||
resolved "https://registry.yarnpkg.com/whatwg-fetch/-/whatwg-fetch-2.0.3.tgz#9c84ec2dcf68187ff00bc64e1274b442176e1c84"
|
||||
@@ -1,7 +1,3 @@
|
||||
export const loginWithFacebook = () => (dispatch, _, { rest }) => {
|
||||
window.open(
|
||||
`${rest.uri}/auth/facebook`,
|
||||
'Continue with Facebook',
|
||||
'menubar=0,resizable=0,width=500,height=500,top=200,left=500'
|
||||
);
|
||||
window.location = `${rest.uri}/auth/facebook`;
|
||||
};
|
||||
|
||||
@@ -1,24 +1,31 @@
|
||||
module.exports = router => {
|
||||
const { passport, HandleAuthPopupCallback } = require('services/passport');
|
||||
|
||||
/**
|
||||
* Facebook auth endpoint, this will redirect the user immediatly to facebook
|
||||
* Facebook auth endpoint, this will redirect the user immediately to Facebook
|
||||
* for authorization.
|
||||
*/
|
||||
router.get(
|
||||
'/api/v1/auth/facebook',
|
||||
passport.authenticate('facebook', {
|
||||
router.get('/api/v1/auth/facebook', (req, res, next) => {
|
||||
const {
|
||||
connectors: { services: { Passport: { passport } } },
|
||||
} = req.context;
|
||||
|
||||
return passport.authenticate('facebook', {
|
||||
display: 'popup',
|
||||
authType: 'rerequest',
|
||||
scope: ['public_profile'],
|
||||
})
|
||||
);
|
||||
})(req, res, next);
|
||||
});
|
||||
|
||||
/**
|
||||
* Facebook callback endpoint, this will send the user a html page designed to
|
||||
* send back the user credentials upon sucesfull login.
|
||||
* Facebook callback endpoint, this will send the user a HTML page designed to
|
||||
* send back the user credentials upon successful login.
|
||||
*/
|
||||
router.get('/api/v1/auth/facebook/callback', (req, res, next) => {
|
||||
const {
|
||||
connectors: {
|
||||
services: { Passport: { passport, HandleAuthPopupCallback } },
|
||||
},
|
||||
} = req.context;
|
||||
|
||||
// Perform the facebook login flow and pass the data back through the opener.
|
||||
passport.authenticate(
|
||||
'facebook',
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
|
||||
# yarn lockfile v1
|
||||
|
||||
|
||||
oauth@0.9.x:
|
||||
version "0.9.15"
|
||||
resolved "https://registry.yarnpkg.com/oauth/-/oauth-0.9.15.tgz#bd1fefaf686c96b75475aed5196412ff60cfb9c1"
|
||||
|
||||
passport-facebook@^2.1.1:
|
||||
version "2.1.1"
|
||||
resolved "https://registry.yarnpkg.com/passport-facebook/-/passport-facebook-2.1.1.tgz#c39d0b52ae4d59163245a4e21a7b9b6321303311"
|
||||
dependencies:
|
||||
passport-oauth2 "1.x.x"
|
||||
|
||||
passport-oauth2@1.x.x:
|
||||
version "1.4.0"
|
||||
resolved "https://registry.yarnpkg.com/passport-oauth2/-/passport-oauth2-1.4.0.tgz#f62f81583cbe12609be7ce6f160b9395a27b86ad"
|
||||
dependencies:
|
||||
oauth "0.9.x"
|
||||
passport-strategy "1.x.x"
|
||||
uid2 "0.0.x"
|
||||
utils-merge "1.x.x"
|
||||
|
||||
passport-strategy@1.x.x:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/passport-strategy/-/passport-strategy-1.0.0.tgz#b5539aa8fc225a3d1ad179476ddf236b440f52e4"
|
||||
|
||||
uid2@0.0.x:
|
||||
version "0.0.3"
|
||||
resolved "https://registry.yarnpkg.com/uid2/-/uid2-0.0.3.tgz#483126e11774df2f71b8b639dcd799c376162b82"
|
||||
|
||||
utils-merge@1.x.x:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.0.tgz#0294fb922bb9375153541c4f7096231f287c8af8"
|
||||
@@ -1,4 +0,0 @@
|
||||
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
|
||||
# yarn lockfile v1
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "@coralproject/eslint-config-talk"
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user