mirror of
https://github.com/wassname/talk.git
synced 2026-09-14 11:36:51 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db183124c9 | ||
|
|
ee220ca936 | ||
|
|
907e9bf226 | ||
|
|
25bccde40f | ||
|
|
6343a0d621 | ||
|
|
6e6caaf5a9 | ||
|
|
8261c40c8d | ||
|
|
316b0a8808 | ||
|
|
ebce930b8c | ||
|
|
8c8a2fcee0 | ||
|
|
1733ab8690 | ||
|
|
9624a12e39 | ||
|
|
143050cbd5 | ||
|
|
0fd7d9f020 | ||
|
|
7ac3a325f7 | ||
|
|
149389057d | ||
|
|
3b01218338 | ||
|
|
2d8e36e511 | ||
|
|
9c9f088ac3 | ||
|
|
392d82e30a | ||
|
|
f86d210bbb | ||
|
|
11ea45ef69 | ||
|
|
b0cbddd313 | ||
|
|
1f8f293594 | ||
|
|
7ca7166c7a | ||
|
|
65500eda64 | ||
|
|
0e37f36e02 | ||
|
|
abed842c23 | ||
|
|
e1d1549c93 | ||
|
|
8f820ace5b | ||
|
|
1069498323 | ||
|
|
c34daacd8c | ||
|
|
f7f5b42b71 | ||
|
|
f8a3b84e94 | ||
|
|
44e89bc96c | ||
|
|
2342934f48 | ||
|
|
5328374b73 | ||
|
|
6f64eac5de | ||
|
|
0b884a7664 | ||
|
|
64602f1731 | ||
|
|
3bc26cad80 | ||
|
|
def49d42aa | ||
|
|
3d4c296837 | ||
|
|
64571d3848 | ||
|
|
ad669b8399 | ||
|
|
1c8e009366 | ||
|
|
5d8528fdb5 | ||
|
|
6cab2634bf | ||
|
|
d6adff288c | ||
|
|
ad4b03bfcd | ||
|
|
99b8b4e90b | ||
|
|
3fa475009a | ||
|
|
139802381f | ||
|
|
fd0143da5d | ||
|
|
baa4808364 | ||
|
|
673f0cd232 | ||
|
|
fa02d44270 | ||
|
|
84edd8c6c2 | ||
|
|
da2bc69d19 | ||
|
|
b769e95898 | ||
|
|
2b7f46efc7 | ||
|
|
7005175fc0 | ||
|
|
8f64229dee | ||
|
|
47a5b84a74 | ||
|
|
cc1026233f | ||
|
|
6f1b0b55ec | ||
|
|
dcdfbb691a | ||
|
|
55ff0bcf8c | ||
|
|
d61374993a | ||
|
|
980dc2515e | ||
|
|
4511b9ca57 | ||
|
|
52cd7bcce4 | ||
|
|
5fee26da0e | ||
|
|
d99f6d8923 | ||
|
|
f051ee1d58 | ||
|
|
cd1735e1d6 | ||
|
|
3b10aa3945 | ||
|
|
3b0b6bd73c | ||
|
|
760d078705 | ||
|
|
46473142ba | ||
|
|
8a619a219f | ||
|
|
73d15f34d7 | ||
|
|
cf56ca8263 | ||
|
|
1183550e14 | ||
|
|
6652368221 | ||
|
|
d488e168c4 | ||
|
|
9990142740 | ||
|
|
33bd2f576a | ||
|
|
19929c97d8 | ||
|
|
99fc67d0d9 | ||
|
|
636d1f4788 | ||
|
|
d45038ccc5 | ||
|
|
9746422b16 | ||
|
|
4ee727c86a | ||
|
|
ad09ce03a1 | ||
|
|
afeeffc262 | ||
|
|
043d5fdcec | ||
|
|
8c7c564a30 | ||
|
|
13cac731b9 | ||
|
|
ab87500303 | ||
|
|
244d421eda | ||
|
|
d5dfd89d94 | ||
|
|
d82735b2b5 | ||
|
|
b091cf5221 | ||
|
|
d90f87428c | ||
|
|
41f5297d78 | ||
|
|
0e9d9067f9 | ||
|
|
d2e6a98d46 | ||
|
|
4dd03508ee | ||
|
|
fb2662572a | ||
|
|
0069f31244 | ||
|
|
b235b8334e | ||
|
|
acb7028471 | ||
|
|
9daaa6ffee | ||
|
|
af3489db9c | ||
|
|
012fa3e4aa | ||
|
|
a8467db7aa | ||
|
|
05e2302794 | ||
|
|
c610c319de | ||
|
|
04d330cf5b | ||
|
|
8cbb56af19 | ||
|
|
d4cb07e448 | ||
|
|
71b4413f92 | ||
|
|
9455eea041 | ||
|
|
e8bbd7e4d7 | ||
|
|
94032b69fc | ||
|
|
1f199ad3dd | ||
|
|
eb038b9d49 | ||
|
|
b8d00ec9ce | ||
|
|
b5ce16a33b | ||
|
|
f8a6987476 | ||
|
|
28e589de21 | ||
|
|
1b97811cb0 | ||
|
|
31cfddf93c | ||
|
|
562f2350c1 | ||
|
|
ea6330bf94 | ||
|
|
0be3dc2ceb | ||
|
|
eb98b32d21 | ||
|
|
127ee25d7f | ||
|
|
ed4a3c3250 | ||
|
|
7b53048a91 | ||
|
|
28e94d30b0 | ||
|
|
1c2fec29a4 | ||
|
|
56cc4989fd | ||
|
|
d72df73d6f | ||
|
|
b32aebd6f1 | ||
|
|
244a276e1a | ||
|
|
245561bd52 | ||
|
|
d05a63bc2e | ||
|
|
5d118c26d1 | ||
|
|
5ab5b7ca70 | ||
|
|
7ba11cdee9 | ||
|
|
b79c990ee5 | ||
|
|
1541a618b5 | ||
|
|
8d4c0c6721 | ||
|
|
195592b9ab | ||
|
|
da46d27e5b | ||
|
|
bfbe8d44aa | ||
|
|
da6965122f | ||
|
|
30b200d734 | ||
|
|
dc76d89c47 | ||
|
|
3d80df1db6 | ||
|
|
83832abd72 | ||
|
|
47fd522f68 | ||
|
|
1280b16bcd | ||
|
|
3d666111b0 | ||
|
|
919bdff40a | ||
|
|
518b7b5358 | ||
|
|
8b9463f501 | ||
|
|
f5313c0aed | ||
|
|
c59c09e1f4 | ||
|
|
e98dcb6647 | ||
|
|
39db11569f | ||
|
|
c3ad47f6f6 | ||
|
|
b9cb1a783f | ||
|
|
ea51fdf6cc | ||
|
|
324aa6db32 | ||
|
|
9065a64d13 | ||
|
|
9b36fcebe9 | ||
|
|
5d9d01d87f | ||
|
|
7f85129044 | ||
|
|
fc31a2882d | ||
|
|
23cc3548d4 | ||
|
|
574f5b7cea | ||
|
|
b19e050399 | ||
|
|
ef3c82fee1 | ||
|
|
32211c4e37 | ||
|
|
202e7a2b10 | ||
|
|
2588f3946d | ||
|
|
1c2e850a07 | ||
|
|
0e8ecb37f3 | ||
|
|
d1b96f5830 | ||
|
|
a9c6014634 | ||
|
|
125168c7c8 | ||
|
|
eafcadd6a1 | ||
|
|
211f24476a | ||
|
|
f55e4ef895 | ||
|
|
bbf1da88e6 | ||
|
|
d688f70c19 | ||
|
|
da7aa8f6d0 | ||
|
|
b04a477f6a | ||
|
|
0546f741a5 | ||
|
|
a535baeb31 | ||
|
|
8b125eab48 | ||
|
|
053b687959 | ||
|
|
b620ac703b | ||
|
|
3cb0bb052c | ||
|
|
c4985e0007 | ||
|
|
066d9ce0ec | ||
|
|
dc6fa74d22 | ||
|
|
e9e79b4605 | ||
|
|
1d9b627d73 | ||
|
|
40f4419c9d | ||
|
|
8b70dc688d | ||
|
|
b05a6c00ce | ||
|
|
f0bdee415b | ||
|
|
9063b8adf1 | ||
|
|
01fd667eb4 | ||
|
|
73ac8d16ef | ||
|
|
ced449a148 | ||
|
|
ffad851066 | ||
|
|
4281fd590c | ||
|
|
0c167fdfc4 | ||
|
|
5dfb372c42 | ||
|
|
932103c204 | ||
|
|
666225614b | ||
|
|
636f357186 | ||
|
|
be6337f17c | ||
|
|
2c7f5ca089 | ||
|
|
f09550a4ac | ||
|
|
3c3a0523de | ||
|
|
a2dabcac0e | ||
|
|
a686bc75a7 | ||
|
|
8960f67a4d | ||
|
|
e10c1f1a1f | ||
|
|
55e70dfbec | ||
|
|
af46e3a7b1 | ||
|
|
883dfedfab | ||
|
|
ae52b27009 | ||
|
|
34a4a005d1 | ||
|
|
ab72995a2c | ||
|
|
aac47c4686 | ||
|
|
362c072181 | ||
|
|
ef172ae5f8 | ||
|
|
1e4e1cb413 | ||
|
|
e819b96473 | ||
|
|
1e3c1feadf | ||
|
|
83d7e599c7 | ||
|
|
52d7ba8965 | ||
|
|
c3ec772ea2 | ||
|
|
d2b4c2bcac | ||
|
|
aad2455654 | ||
|
|
a8e5ad451d | ||
|
|
99425e89ca | ||
|
|
60608af8b2 | ||
|
|
7c753ee518 | ||
|
|
70d1d5e9de | ||
|
|
28010488be | ||
|
|
2a9535f88a | ||
|
|
f9bece468f | ||
|
|
38e8bed68a | ||
|
|
3ca62cab65 | ||
|
|
da6bd80de0 | ||
|
|
774e0cc12e | ||
|
|
dcfbcd287b | ||
|
|
4af9c3bee2 | ||
|
|
ec559f0f23 | ||
|
|
b902695cb7 | ||
|
|
34dbbb0984 | ||
|
|
002dc377a6 | ||
|
|
fbdb044551 | ||
|
|
23ebe9f8e3 | ||
|
|
e3aecd645e | ||
|
|
b8020d5347 | ||
|
|
95e820e9de |
@@ -13,4 +13,6 @@ plugins/*
|
||||
!plugins/coral-plugin-viewing-options
|
||||
!plugins/coral-plugin-comment-content
|
||||
!plugins/talk-plugin-permalink
|
||||
!plugins/talk-plugin-featured-comments
|
||||
|
||||
node_modules
|
||||
|
||||
@@ -26,5 +26,6 @@ plugins/*
|
||||
!plugins/coral-plugin-viewing-options
|
||||
!plugins/coral-plugin-comment-content
|
||||
!plugins/talk-plugin-permalink
|
||||
!plugins/talk-plugin-featured-comments
|
||||
|
||||
**/node_modules/*
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We expect everyone contributing to The Coral Project to follow this code of conduct. That means the team, contractors we employ, contributors, as well as anyone posting to our public or internal-facing channels.
|
||||
We created it not because we anticipate any unacceptable behavior, but because we believe that articulating our values and obligations to one another reinforces the already exceptional level of respect among the team, and because having a code provides us with clear avenues to correct our culture should it ever stray from that course.
|
||||
|
||||
We commit to enforce and evolve this code over the duration of the project.
|
||||
|
||||
## Expected behavior
|
||||
|
||||
|
||||
* Be supportive of each other.
|
||||
* Be collaborative. Involve others in brainstorms, sketching sessions, code reviews, planning documents, and the like. It’s not only okay to ask for help or feedback often, it’s unacceptable not to do so.
|
||||
* Be generous and kind in both giving and accepting critique. Critique is a natural and important part of our culture. Good critiques are kind, respectful, clear, and constructive, focused on goals and requirements rather than personal preferences. You are expected to give and receive criticism with grace.
|
||||
* Be humane. Be polite and friendly in all forms of communication, especially remote communication, where opportunities for misunderstanding are greater. Use sarcasm carefully. Tone is hard to decipher online; make judicious use of emoji to aid in communication.
|
||||
* Be considerate.
|
||||
* Be tolerant.
|
||||
* Respect people’s boundaries.
|
||||
* Do not make it personal.
|
||||
* Use welcoming and inclusive language.
|
||||
* Offer to help if you see someone struggling or otherwise in need of assistance (taking care not to be patronizing or disrespectful).
|
||||
* If someone approaches you looking for help, be generous with your time; if you’re under a deadline, direct them to someone else who may be of assistance.
|
||||
* Go out of your way to include people in jokes or memes, recognizing that we want to build an environment free of cliques.
|
||||
* Show empathy towards other community members
|
||||
|
||||
|
||||
## Unacceptable behavior
|
||||
|
||||
We are committed to providing a welcoming and safe environment for people of all races, gender identities, gender expressions, sexual orientations, physical abilities, physical appearances, socioeconomic backgrounds, nationalities, ages, religions, and beliefs.
|
||||
We expect that you will refrain from demeaning, discriminatory, or harassing behavior and speech.
|
||||
|
||||
Harassment includes, but is not limited to: deliberate intimidation; stalking; unwanted photography or recording; sustained or willful disruption of talks or other events; inappropriate physical contact; use of sexual or discriminatory imagery, comments, or jokes; and unwelcome sexual attention.
|
||||
Furthermore, any behavior or language which is unwelcoming—whether or not it rises to the level of harassment—is also strongly discouraged. Much exclusionary behavior takes the form of microaggressions—subtle put-downs which may be unconsciously delivered. Regardless of intent, microaggressions can have a significant negative impact on victims and have no place on our team.
|
||||
|
||||
Other inappropriate behavior:
|
||||
* Threats
|
||||
* Slurs
|
||||
* Pornography
|
||||
* Spam
|
||||
* Bullying
|
||||
* Copyright infringement
|
||||
* Impersonation of someone else
|
||||
* Violating someone’s privacy
|
||||
|
||||
If you feel that someone has harassed you or otherwise treated you or someone else inappropriately, please alert the project lead at andrewl@mozillafoundation.org.
|
||||
|
||||
|
||||
## Our Responsibilities
|
||||
|
||||
Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior.
|
||||
|
||||
Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers.
|
||||
|
||||
## Enforcement
|
||||
|
||||
These guidelines are ambitious, and we’re not always going to succeed in meeting them. When something goes wrong—whether it’s a microaggression or an instance of harassment — there are a number of things you can do to address the situation. Depending on your comfort level and the severity of the situation, here are some suggestions:
|
||||
|
||||
* Address it directly. If you’re comfortable bringing up the incident with the person who instigated it, pull them aside to discuss how it affected you. Be sure to approach these conversations in a forgiving spirit: an angry or tense conversation will not do either of you any good. If you’re unsure how to go about that, try discussing with your manager or with the people and culture team first—they might have some advice about how to make this conversation happen.
|
||||
If you’re too frustrated to have a direct conversation, there are a number of alternate routes you can take.
|
||||
|
||||
* Talk to a peer or mentor. Your colleagues are likely to have personal and professional experience on which to draw that could be of use to you. If you have someone you’re comfortable approaching, reach out and discuss the situation with them. They may be able to advise on how they would handle it, or direct you to someone who can. The flip side of this, of course, is that you should also be available when your colleagues reach out to you.
|
||||
|
||||
* Contact the project lead, Andrew Losowsky, andrewl@mozillafoundation.org, or the technical lead. We will work with you to help you figure out how to ensure that any conflict doesn’t interfere with your work, in confidence if you would prefer.
|
||||
|
||||
* Talk to Chris Lawrence. Chris oversees the project. He can be contacted at clawrence@mozillafoundation.org.
|
||||
|
||||
If you feel you have been unfairly accused of violating this code of conduct, you should contact Chris with a concise description of your grievance.
|
||||
|
||||
## Conclusion
|
||||
|
||||
We welcome your feedback on this and every other aspect of what we do as The Coral Project, and we thank you for working with us to make it a safe, enjoyable, and friendly experience for everyone involved in the project and what we do.
|
||||
Above text is licensed CC BY-SA 4.0, adapted from the SRCCON code of conduct, FreeBSD’s code of conduct, Vox Media’s product team code of conduct, Medium’s code of conduct, as well as adapted from the Contributor Covenant.
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
FROM node:7.8
|
||||
FROM node:7.10.1
|
||||
|
||||
# Create app directory
|
||||
RUN mkdir -p /usr/src/app
|
||||
|
||||
@@ -19,6 +19,10 @@ See our [Talk Documentation & Guides](https://coralproject.github.io/talk/index.
|
||||
|
||||
See our guide to using and building [Talk Plugins](https://github.com/coralproject/talk/blob/master/PLUGINS.md).
|
||||
|
||||
### Recipes
|
||||
|
||||
Recipes are plugin templates provided by the Coral Core team. Developers can use these recipes to build their own plugins. You can find all the Talk recipes here: https://github.com/coralproject/talk-recipes/
|
||||
|
||||
## Usage
|
||||
|
||||
### Installation
|
||||
@@ -46,11 +50,12 @@ sign and verify tokens via a `HS256` algorithm.
|
||||
- `TALK_SMTP_PASSWORD` (*required for email*) - password for the SMTP provider you are using.
|
||||
- `TALK_SMTP_HOST` (*required for email*) - SMTP host url with format `smtp.domain.com`.
|
||||
- `TALK_SMTP_PORT` (*required for email*) - SMTP port.
|
||||
- `TALK_INSTALL_LOCK` (_optional for dynamic setup_) - Defaults to `FALSE`. When `TRUE`, disables the dynamic setup endpoint.
|
||||
- `TALK_INSTALL_LOCK` (_optional for dynamic setup_) - When `TRUE`, disables the dynamic setup endpoint. (Default `FALSE`)
|
||||
- `TALK_RECAPTCHA_SECRET` (*required for reCAPTCHA support*) - server secret used for enabling reCAPTCHA powered logins. If not provided it will instead default to providing only a time based lockout.
|
||||
- `TALK_RECAPTCHA_PUBLIC` (*required for reCAPTCHA support*) - client secret used for enabling reCAPTCHA powered logins. If not provided it will instead default to providing only a time based lockout.
|
||||
- `TALK_PLUGINS_JSON` (_optional_) - used to specify the plugin config via the environment
|
||||
- `TALK_KEEP_ALIVE` (_optional_) - The keepalive timeout that should be used to send keep alive messages through the websocket to keep the socket alive. (Default `30s`)
|
||||
- `TALK_DISABLE_AUTOFLAG_SUSPECT_WORDS` (_optional_) When `TRUE`, disables flagging of comments that match the suspect word filter. (Default `FALSE`)
|
||||
|
||||
Refer to the wiki page on [Configuration Loading](https://github.com/coralproject/talk/wiki/Configuration-Loading) for
|
||||
alternative methods of loading configuration during development.
|
||||
|
||||
+120
-1
@@ -8,13 +8,14 @@
|
||||
// https://yarnpkg.com/
|
||||
|
||||
const program = require('./commander');
|
||||
const inquirer = require('inquirer');
|
||||
|
||||
// Make things colorful!
|
||||
require('colors');
|
||||
const emoji = require('node-emoji');
|
||||
|
||||
const dir = process.cwd();
|
||||
const fs = require('fs');
|
||||
const fs = require('fs-extra');
|
||||
const path = require('path');
|
||||
const spawn = require('cross-spawn');
|
||||
const semver = require('semver');
|
||||
@@ -272,10 +273,128 @@ async function reconcilePluginDeps({skipLocal, skipRemote, dryRun, upgradeRemote
|
||||
console.log(`✨ Done in ${totalTime}s.`);
|
||||
}
|
||||
|
||||
async function createSeedPlugin() {
|
||||
const pluginsDir = path.join(__dirname, 'plugins');
|
||||
|
||||
function pluginNameExists(pluginName) {
|
||||
const pluginNames = fs.readdirSync(pluginsDir);
|
||||
return !!pluginNames
|
||||
.filter((pn) => pn === pluginName).length;
|
||||
}
|
||||
|
||||
let answers = await inquirer.prompt([
|
||||
{
|
||||
type: 'input',
|
||||
name: 'pluginName',
|
||||
message: 'Plugin Name:',
|
||||
validate: (input) => {
|
||||
|
||||
if (pluginNameExists(input)) {
|
||||
return 'Please, choose another name. That name already exists';
|
||||
}
|
||||
|
||||
if (input && input.length > 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return 'Plugin Name is required.';
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'confirm',
|
||||
name: 'server',
|
||||
message: 'Is this plugin extending the server capabilities?'
|
||||
},
|
||||
{
|
||||
type: 'confirm',
|
||||
name: 'client',
|
||||
message: 'Is this plugin extending the client capabilities?'
|
||||
},
|
||||
{
|
||||
type: 'confirm',
|
||||
name: 'addPluginsJson',
|
||||
message: 'Should we add it to the plugins.json?'
|
||||
}
|
||||
]);
|
||||
|
||||
//==============================================================================
|
||||
// Creating plugin seed
|
||||
//==============================================================================
|
||||
|
||||
const seedPlugin = path.join(__dirname, 'bin/templates/plugin');
|
||||
const newPluginPath = path.join(pluginsDir, answers.pluginName);
|
||||
|
||||
if (fs.existsSync(seedPlugin)) {
|
||||
|
||||
if (answers.server && answers.client) {
|
||||
|
||||
// This is a server-side and client-side plugin!, let's copy the template
|
||||
fs.copySync(seedPlugin, newPluginPath);
|
||||
} else {
|
||||
|
||||
fs.copySync(seedPlugin, newPluginPath, {filter: (p) => {
|
||||
|
||||
// Allowing plugin folder and files with no subfolders
|
||||
const rootRx = /plugin$|plugin\/[^/]*(\.).{2,3}/igm;
|
||||
if (rootRx.test(p) && (fs.lstatSync(p).isDirectory() || fs.lstatSync(p).isFile())) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If it's a client-side plugin, copying client folder
|
||||
if (answers.client) {
|
||||
return /client/.test(p);
|
||||
}
|
||||
|
||||
// If it's a server-side plugin, copying server folder
|
||||
if (answers.server) {
|
||||
return /server/.test(p);
|
||||
}
|
||||
|
||||
}});
|
||||
}
|
||||
|
||||
// Let's add this to the plugins.json
|
||||
if (answers.addPluginsJson) {
|
||||
const pluginsJson = path.join(dir, 'plugins.json');
|
||||
|
||||
fs.readJson(pluginsJson)
|
||||
.then((j) => {
|
||||
|
||||
// This is a client-side plugin, let's push this.
|
||||
if (answers.client) {
|
||||
j.client.push(answers.pluginName);
|
||||
|
||||
const output = JSON.stringify(j, null, 2);
|
||||
fs.writeFileSync(pluginsJson, output);
|
||||
}
|
||||
|
||||
// This is a server-side plugin, let's push this.
|
||||
if (answers.server) {
|
||||
j.server.push(answers.pluginName);
|
||||
|
||||
const output = JSON.stringify(j, null, 2);
|
||||
fs.writeFileSync(pluginsJson, output);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`✨ Yay! Plugin created! Find your plugin: ${answers.pluginName} in the ./plugins folder`);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
//==============================================================================
|
||||
// Setting up the program command line arguments.
|
||||
//==============================================================================
|
||||
|
||||
program
|
||||
.command('create')
|
||||
.description('creates a seed plugin')
|
||||
.action(createSeedPlugin);
|
||||
|
||||
program
|
||||
.command('list')
|
||||
.description('')
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"presets": [
|
||||
"es2015"
|
||||
],
|
||||
"plugins": [
|
||||
"add-module-exports",
|
||||
"transform-class-properties",
|
||||
"transform-decorators-legacy",
|
||||
"transform-object-assign",
|
||||
"transform-object-rest-spread",
|
||||
"transform-async-to-generator",
|
||||
"transform-react-jsx"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"env": {
|
||||
"browser": true,
|
||||
"es6": true,
|
||||
"mocha": true
|
||||
},
|
||||
"parserOptions": {
|
||||
"sourceType": "module",
|
||||
"ecmaFeatures": {
|
||||
"experimentalObjectRestSpread": true,
|
||||
"jsx": true
|
||||
}
|
||||
},
|
||||
"parser": "babel-eslint",
|
||||
"plugins": [
|
||||
"react"
|
||||
],
|
||||
"rules": {
|
||||
"react/jsx-uses-react": "error",
|
||||
"react/jsx-uses-vars": "error",
|
||||
"no-console": ["warn", { "allow": ["warn", "error"] }]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
.myPluginContainer {
|
||||
padding: 10px;
|
||||
background: #f0f0f0;
|
||||
border: 1px solid #d6d6d6;
|
||||
margin: 10px 0;
|
||||
text-align: center;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
position: block;
|
||||
animation: spin 2s infinite ease;
|
||||
animation-delay: 1s;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.description {
|
||||
color: #444444;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import React from 'react';
|
||||
import {CoralLogo} from 'plugin-api/beta/client/components/ui';
|
||||
import styles from './MyPluginComponent.css';
|
||||
|
||||
class MyPluginComponent extends React.Component {
|
||||
render() {
|
||||
return (
|
||||
<div className={styles.myPluginContainer}>
|
||||
<CoralLogo className={styles.logo}/>
|
||||
<div className={styles.description}>
|
||||
<h3>Plugin created by Talk CLI</h3>
|
||||
|
||||
<small>
|
||||
To read more about plugins check{' '}
|
||||
<a href="https://coralproject.github.io/talk/plugins-client.html">
|
||||
our docs and guides!
|
||||
</a>
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default MyPluginComponent;
|
||||
@@ -0,0 +1,26 @@
|
||||
|
||||
/**
|
||||
This is a client index example file and it could look like this:
|
||||
|
||||
```
|
||||
import LoveButton from './components/LoveButton';
|
||||
|
||||
export default {
|
||||
slots: {
|
||||
commentReactions: [LoveButton]
|
||||
},
|
||||
reducer,
|
||||
translations
|
||||
};
|
||||
```
|
||||
|
||||
To read more info on how to build client plugins. Please, go to: https://coralproject.github.io/talk/plugins-client.html
|
||||
*/
|
||||
|
||||
import MyPluginComponent from './components/MyPluginComponent';
|
||||
|
||||
export default {
|
||||
slots: {
|
||||
stream: [MyPluginComponent]
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
#
|
||||
# This file is for translations and they should look like this:
|
||||
#
|
||||
#
|
||||
# ```
|
||||
# en:
|
||||
# coral-plugin-respect:
|
||||
# respect: Respect
|
||||
# respected: Respected
|
||||
# es:
|
||||
# coral-plugin-respect:
|
||||
# respect: Respetar
|
||||
# respected: Respetado
|
||||
# ```
|
||||
#
|
||||
@@ -0,0 +1 @@
|
||||
module.exports = {};
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
machine:
|
||||
node:
|
||||
version: 7.9
|
||||
version: 7.10.1
|
||||
services:
|
||||
- docker
|
||||
- redis
|
||||
|
||||
@@ -74,7 +74,7 @@
|
||||
cursor: pointer;
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
height: 50px;
|
||||
min-height: 50px;
|
||||
box-sizing: border-box;
|
||||
transition: background-color 400ms;
|
||||
|
||||
|
||||
@@ -1,22 +1,21 @@
|
||||
import {gql} from 'react-apollo';
|
||||
import Comment from '../components/Comment';
|
||||
import {getSlotsFragments} from 'coral-framework/helpers/plugins';
|
||||
import withFragments from 'coral-framework/hocs/withFragments';
|
||||
import {getSlotFragmentSpreads} from 'coral-framework/utils';
|
||||
|
||||
const pluginFragments = getSlotsFragments([
|
||||
const slots = [
|
||||
'adminCommentInfoBar',
|
||||
'adminCommentContent',
|
||||
'adminSideActions',
|
||||
'adminCommentDetailArea',
|
||||
]);
|
||||
];
|
||||
|
||||
export default withFragments({
|
||||
root: gql`
|
||||
fragment CoralAdmin_ModerationComment_root on RootQuery {
|
||||
__typename
|
||||
${pluginFragments.spreads('root')}
|
||||
${getSlotFragmentSpreads(slots, 'root')}
|
||||
}
|
||||
${pluginFragments.definitions('root')}
|
||||
`,
|
||||
comment: gql`
|
||||
fragment CoralAdmin_ModerationComment_comment on Comment {
|
||||
@@ -52,8 +51,7 @@ export default withFragments({
|
||||
editing {
|
||||
edited
|
||||
}
|
||||
${pluginFragments.spreads('comment')}
|
||||
${getSlotFragmentSpreads(slots, 'comment')}
|
||||
}
|
||||
${pluginFragments.definitions('comment')}
|
||||
`
|
||||
})(Comment);
|
||||
|
||||
@@ -4,8 +4,7 @@ import {connect} from 'react-redux';
|
||||
import {bindActionCreators} from 'redux';
|
||||
import UserDetail from '../components/UserDetail';
|
||||
import withQuery from 'coral-framework/hocs/withQuery';
|
||||
import {getSlotsFragments} from 'coral-framework/helpers/plugins';
|
||||
import {getDefinitionName} from 'coral-framework/utils';
|
||||
import {getDefinitionName, getSlotFragmentSpreads} from 'coral-framework/utils';
|
||||
import {
|
||||
changeUserDetailStatuses,
|
||||
clearUserDetailSelections,
|
||||
@@ -26,9 +25,9 @@ const commentConnectionFragment = gql`
|
||||
${Comment.fragments.comment}
|
||||
`;
|
||||
|
||||
const pluginFragments = getSlotsFragments([
|
||||
const slots = [
|
||||
'userProfile',
|
||||
]);
|
||||
];
|
||||
|
||||
class UserDetailContainer extends React.Component {
|
||||
static propTypes = {
|
||||
@@ -80,7 +79,7 @@ export const withUserDetailQuery = withQuery(gql`
|
||||
id
|
||||
provider
|
||||
}
|
||||
${pluginFragments.spreads('user')}
|
||||
${getSlotFragmentSpreads(slots, 'user')}
|
||||
}
|
||||
totalComments: commentCount(query: {author_id: $author_id})
|
||||
rejectedComments: commentCount(query: {author_id: $author_id, statuses: [REJECTED]})
|
||||
@@ -90,11 +89,8 @@ export const withUserDetailQuery = withQuery(gql`
|
||||
}) {
|
||||
...CoralAdmin_Moderation_CommentConnection
|
||||
}
|
||||
${pluginFragments.spreads('root')}
|
||||
${getSlotFragmentSpreads(slots, 'root')}
|
||||
}
|
||||
${Comment.fragments.comment}
|
||||
${pluginFragments.definitions('user')}
|
||||
${pluginFragments.definitions('root')}
|
||||
${commentConnectionFragment}
|
||||
`, {
|
||||
options: ({id, moderation: {userDetailStatuses: statuses}}) => {
|
||||
|
||||
@@ -1,35 +1,24 @@
|
||||
import pym from 'coral-framework/services/pym';
|
||||
import * as actions from '../constants/stream';
|
||||
import {buildUrl} from 'coral-framework/utils';
|
||||
import queryString from 'query-string';
|
||||
|
||||
export const setActiveReplyBox = (id) => ({type: actions.SET_ACTIVE_REPLY_BOX, id});
|
||||
|
||||
function removeParam(key, sourceURL) {
|
||||
let rtn = sourceURL.split('?')[0];
|
||||
let param;
|
||||
let params_arr = [];
|
||||
let queryString = (sourceURL.indexOf('?') !== -1) ? sourceURL.split('?')[1] : '';
|
||||
if (queryString !== '') {
|
||||
params_arr = queryString.split('&');
|
||||
for (let i = params_arr.length - 1; i >= 0; i -= 1) {
|
||||
param = params_arr[i].split('=')[0];
|
||||
if (param === key) {
|
||||
params_arr.splice(i, 1);
|
||||
}
|
||||
}
|
||||
rtn = `${rtn}?${params_arr.join('&')}`;
|
||||
}
|
||||
return rtn;
|
||||
}
|
||||
|
||||
export const viewAllComments = () => {
|
||||
|
||||
const search = queryString.stringify({
|
||||
...queryString.parse(location.search),
|
||||
comment_id: undefined,
|
||||
});
|
||||
|
||||
// remove the comment_id url param
|
||||
const modifiedUrl = removeParam('comment_id', location.href);
|
||||
const url = buildUrl({...location, search});
|
||||
|
||||
try {
|
||||
|
||||
// "window" here refers to the embedded iframe
|
||||
window.history.replaceState({}, document.title, modifiedUrl);
|
||||
window.history.replaceState({}, document.title, url);
|
||||
|
||||
// also change the parent url
|
||||
pym.sendMessage('coral-view-all-comments');
|
||||
@@ -38,6 +27,28 @@ export const viewAllComments = () => {
|
||||
return {type: actions.VIEW_ALL_COMMENTS};
|
||||
};
|
||||
|
||||
export const viewComment = (id) => {
|
||||
|
||||
const search = queryString.stringify({
|
||||
...queryString.parse(location.search),
|
||||
comment_id: id,
|
||||
});
|
||||
|
||||
// remove the comment_id url param
|
||||
const url = buildUrl({...location, search});
|
||||
|
||||
try {
|
||||
|
||||
// "window" here refers to the embedded iframe
|
||||
window.history.replaceState({}, document.title, url);
|
||||
|
||||
// also change the parent url
|
||||
pym.sendMessage('coral-view-comment', id);
|
||||
} catch (e) { /* not sure if we're worried about old browsers */ }
|
||||
|
||||
return {type: actions.VIEW_COMMENT, id};
|
||||
};
|
||||
|
||||
export const addCommentClassName = (className) => ({
|
||||
type: actions.ADD_COMMENT_CLASSNAME,
|
||||
className
|
||||
@@ -47,3 +58,7 @@ export const removeCommentClassName = (idx) => ({
|
||||
type: actions.REMOVE_COMMENT_CLASSNAME,
|
||||
idx
|
||||
});
|
||||
|
||||
export const setActiveTab = (tab) => (dispatch) => {
|
||||
dispatch({type: actions.SET_ACTIVE_TAB, tab});
|
||||
};
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
import React from 'react';
|
||||
|
||||
import LoadMore from './LoadMore';
|
||||
import IgnoredCommentTombstone from './IgnoredCommentTombstone';
|
||||
import NewCount from './NewCount';
|
||||
import {TransitionGroup} from 'react-transition-group';
|
||||
import {forEachError} from 'coral-framework/utils';
|
||||
import Comment from '../components/Comment';
|
||||
|
||||
const hasComment = (nodes, id) => nodes.some((node) => node.id === id);
|
||||
|
||||
// resetCursors will return the id cursors of the first and second comment of
|
||||
// the current comment list. The cursors are used to dertermine which
|
||||
// comments to show. The spare cursor functions as a backup in case one
|
||||
// of the comments gets deleted.
|
||||
function resetCursors(state, props) {
|
||||
const comments = props.root.asset.comments;
|
||||
if (comments && comments.nodes.length) {
|
||||
const idCursors = [comments.nodes[0].id];
|
||||
if (comments.nodes[1]) {
|
||||
idCursors.push(comments.nodes[1].id);
|
||||
}
|
||||
return {idCursors};
|
||||
}
|
||||
return {idCursors: []};
|
||||
}
|
||||
|
||||
// invalidateCursor is called whenever a comment is removed which is referenced
|
||||
// by one of the 2 id cursors. It returns a new set of id cursors calculated
|
||||
// using the help of the backup cursor.
|
||||
function invalidateCursor(invalidated, state, props) {
|
||||
const alt = invalidated === 1 ? 0 : 1;
|
||||
const comments = props.root.asset.comments;
|
||||
const idCursors = [];
|
||||
if (state.idCursors[alt]) {
|
||||
idCursors.push(state.idCursors[alt]);
|
||||
const index = comments.nodes.findIndex((node) => node.id === idCursors[0]);
|
||||
const nextInLine = comments.nodes[index + 1];
|
||||
if (nextInLine) {
|
||||
idCursors.push(nextInLine.id);
|
||||
}
|
||||
}
|
||||
return {idCursors};
|
||||
}
|
||||
|
||||
class AllCommentsPane extends React.Component {
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
...resetCursors(this.state, props),
|
||||
loadingState: '',
|
||||
};
|
||||
}
|
||||
|
||||
componentWillReceiveProps(next) {
|
||||
const {comments: prevComments} = this.props;
|
||||
const {comments: nextComments} = next;
|
||||
|
||||
if (!prevComments && nextComments) {
|
||||
this.setState(resetCursors);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
prevComments && nextComments &&
|
||||
nextComments.nodes.length < prevComments.nodes.length
|
||||
) {
|
||||
|
||||
// Invalidate first cursor if referenced comment was removed.
|
||||
if (this.state.idCursors[0] && !hasComment(nextComments.nodes, this.state.idCursors[0])) {
|
||||
this.setState(invalidateCursor(0, this.state, next));
|
||||
}
|
||||
|
||||
// Invalidate second cursor if referenced comment was removed.
|
||||
if (this.state.idCursors[1] && !hasComment(nextComments.nodes, this.state.idCursors[1])) {
|
||||
this.setState(invalidateCursor(1, this.state, next));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadMore = () => {
|
||||
this.setState({loadingState: 'loading'});
|
||||
this.props.loadMore()
|
||||
.then(() => {
|
||||
this.setState({loadingState: 'success'});
|
||||
})
|
||||
.catch((error) => {
|
||||
this.setState({loadingState: 'error'});
|
||||
forEachError(error, ({msg}) => {this.props.addNotification('error', msg);});
|
||||
});
|
||||
}
|
||||
|
||||
viewNewComments = () => {
|
||||
this.setState(resetCursors);
|
||||
};
|
||||
|
||||
// getVisibileComments returns a list containing comments
|
||||
// which were authored by current user or comes after the `idCursor`.
|
||||
getVisibleComments() {
|
||||
const {comments, currentUser: user} = this.props;
|
||||
const idCursor = this.state.idCursors[0];
|
||||
const userId = user ? user.id : null;
|
||||
|
||||
if (!comments) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const view = [];
|
||||
let pastCursor = false;
|
||||
comments.nodes.forEach((comment) => {
|
||||
if (comment.id === idCursor) {
|
||||
pastCursor = true;
|
||||
}
|
||||
if (pastCursor || comment.user.id === userId) {
|
||||
view.push(comment);
|
||||
}
|
||||
});
|
||||
return view;
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
data,
|
||||
root,
|
||||
comments,
|
||||
commentClassNames,
|
||||
ignoreUser,
|
||||
setActiveReplyBox,
|
||||
activeReplyBox,
|
||||
addNotification,
|
||||
disableReply,
|
||||
postComment,
|
||||
asset,
|
||||
currentUser,
|
||||
postFlag,
|
||||
postDontAgree,
|
||||
loadNewReplies,
|
||||
deleteAction,
|
||||
showSignInDialog,
|
||||
commentIsIgnored,
|
||||
charCountEnable,
|
||||
maxCharCount,
|
||||
editComment,
|
||||
} = this.props;
|
||||
|
||||
const {loadingState} = this.state;
|
||||
const view = this.getVisibleComments();
|
||||
|
||||
return (
|
||||
<div className="talk-stream-comments-container">
|
||||
<NewCount
|
||||
count={comments.nodes.length - view.length}
|
||||
loadMore={this.viewNewComments}
|
||||
/>
|
||||
<TransitionGroup component='div' className="embed__stream">
|
||||
{view.map((comment) => {
|
||||
return commentIsIgnored(comment)
|
||||
? <IgnoredCommentTombstone key={comment.id} />
|
||||
: <Comment
|
||||
commentClassNames={commentClassNames}
|
||||
data={data}
|
||||
root={root}
|
||||
disableReply={disableReply}
|
||||
setActiveReplyBox={setActiveReplyBox}
|
||||
activeReplyBox={activeReplyBox}
|
||||
addNotification={addNotification}
|
||||
depth={0}
|
||||
postComment={postComment}
|
||||
asset={asset}
|
||||
currentUser={currentUser}
|
||||
postFlag={postFlag}
|
||||
postDontAgree={postDontAgree}
|
||||
ignoreUser={ignoreUser}
|
||||
commentIsIgnored={commentIsIgnored}
|
||||
loadMore={loadNewReplies}
|
||||
deleteAction={deleteAction}
|
||||
showSignInDialog={showSignInDialog}
|
||||
key={comment.id}
|
||||
comment={comment}
|
||||
charCountEnable={charCountEnable}
|
||||
maxCharCount={maxCharCount}
|
||||
editComment={editComment}
|
||||
/>;
|
||||
})}
|
||||
</TransitionGroup>
|
||||
<LoadMore
|
||||
topLevel={true}
|
||||
moreComments={asset.comments.hasNextPage}
|
||||
loadMore={this.loadMore}
|
||||
loadingState={loadingState}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default AllCommentsPane;
|
||||
@@ -1,16 +1,23 @@
|
||||
.root {
|
||||
margin-top: 16px;
|
||||
margin-left: 20px;
|
||||
margin-bottom: 15px;
|
||||
margin-bottom: 16px;
|
||||
position: relative;
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
.rootLevel0:first-child {
|
||||
padding-top: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.rootLevel0 {
|
||||
margin-left: 0px;
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.comment {
|
||||
padding-left: 20px;
|
||||
position: relative;
|
||||
padding-left: 15px;
|
||||
}
|
||||
|
||||
.commentLevel0 {
|
||||
@@ -34,7 +41,7 @@
|
||||
}
|
||||
|
||||
.highlightedComment {
|
||||
padding-left: 20px;
|
||||
padding-left: 15px;
|
||||
border-left: 3px solid rgb(35,118,216);
|
||||
}
|
||||
|
||||
@@ -52,15 +59,11 @@
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* element in the top right of the Comment */
|
||||
.topRight {
|
||||
float: right;
|
||||
margin-top: 10px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.topRight > * {
|
||||
text-align: initial;
|
||||
.hr {
|
||||
border: 0;
|
||||
height: 0;
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.topRight .popover {
|
||||
@@ -74,11 +77,6 @@
|
||||
border-bottom: 2px solid currentColor;
|
||||
}
|
||||
|
||||
.topRightMenu {
|
||||
cursor: pointer;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.editCommentForm {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
@@ -129,7 +127,7 @@
|
||||
}
|
||||
|
||||
.commentInfoBar {
|
||||
float: right;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
@keyframes enter {
|
||||
@@ -141,3 +139,35 @@
|
||||
.enter {
|
||||
animation: enter 1000ms;
|
||||
}
|
||||
|
||||
.commentContainer {
|
||||
}
|
||||
|
||||
.commentAvatar {
|
||||
max-width: 60px;
|
||||
}
|
||||
|
||||
.commentAvatar:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.timerIcon {
|
||||
vertical-align: middle;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.commentFooter {
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.content {
|
||||
}
|
||||
|
||||
.footer {
|
||||
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, {PropTypes} from 'react';
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import AuthorName from 'coral-plugin-author-name/AuthorName';
|
||||
import TagLabel from 'coral-plugin-tag-label/TagLabel';
|
||||
@@ -13,13 +14,6 @@ import {THREADING_LEVEL} from '../constants/stream';
|
||||
import merge from 'lodash/merge';
|
||||
import mapValues from 'lodash/mapValues';
|
||||
|
||||
import {
|
||||
BestButton,
|
||||
IfUserCanModifyBest,
|
||||
BEST_TAG,
|
||||
commentIsBest,
|
||||
BestIndicator
|
||||
} from 'coral-plugin-best/BestButton';
|
||||
import LoadMore from './LoadMore';
|
||||
import {getEditableUntilDate} from './util';
|
||||
import {TopRightMenu} from './TopRightMenu';
|
||||
@@ -134,7 +128,6 @@ export default class Comment extends React.Component {
|
||||
}
|
||||
|
||||
static propTypes = {
|
||||
reactKey: PropTypes.string.isRequired,
|
||||
|
||||
// id of currently opened ReplyBox. tracked in Stream.js
|
||||
activeReplyBox: PropTypes.string.isRequired,
|
||||
@@ -148,12 +141,8 @@ export default class Comment extends React.Component {
|
||||
addNotification: PropTypes.func.isRequired,
|
||||
postComment: PropTypes.func.isRequired,
|
||||
depth: PropTypes.number.isRequired,
|
||||
liveUpdates: PropTypes.bool.isRequired,
|
||||
asset: PropTypes.shape({
|
||||
id: PropTypes.string,
|
||||
title: PropTypes.string,
|
||||
url: PropTypes.string
|
||||
}).isRequired,
|
||||
liveUpdates: PropTypes.bool,
|
||||
asset: PropTypes.object.isRequired,
|
||||
currentUser: PropTypes.shape({
|
||||
id: PropTypes.string.isRequired
|
||||
}),
|
||||
@@ -183,19 +172,13 @@ export default class Comment extends React.Component {
|
||||
}).isRequired,
|
||||
|
||||
// given a comment, return whether it should be rendered as ignored
|
||||
commentIsIgnored: React.PropTypes.func,
|
||||
|
||||
// dispatch action to add a tag to a comment
|
||||
addTag: React.PropTypes.func,
|
||||
|
||||
// dispatch action to remove a tag from a comment
|
||||
removeTag: React.PropTypes.func,
|
||||
commentIsIgnored: PropTypes.func,
|
||||
|
||||
// dispatch action to ignore another user
|
||||
ignoreUser: React.PropTypes.func,
|
||||
ignoreUser: PropTypes.func,
|
||||
|
||||
// edit a comment, passed (id, asset_id, { body })
|
||||
editComment: React.PropTypes.func,
|
||||
editComment: PropTypes.func,
|
||||
}
|
||||
|
||||
editComment = (...args) => {
|
||||
@@ -308,6 +291,8 @@ export default class Comment extends React.Component {
|
||||
render () {
|
||||
const {
|
||||
asset,
|
||||
data,
|
||||
root,
|
||||
depth,
|
||||
comment,
|
||||
postFlag,
|
||||
@@ -326,8 +311,6 @@ export default class Comment extends React.Component {
|
||||
addNotification,
|
||||
charCountEnable,
|
||||
showSignInDialog,
|
||||
addTag,
|
||||
removeTag,
|
||||
liveUpdates,
|
||||
commentIsIgnored,
|
||||
commentClassNames = []
|
||||
@@ -335,7 +318,6 @@ export default class Comment extends React.Component {
|
||||
|
||||
const view = this.getVisibileReplies();
|
||||
const {loadingState} = this.state;
|
||||
const isReply = !!parentId;
|
||||
const isPending = comment.id.indexOf('pending') >= 0;
|
||||
const isHighlighted = highlighted === comment.id;
|
||||
|
||||
@@ -353,40 +335,6 @@ export default class Comment extends React.Component {
|
||||
myFlag = dontAgreeSummary.find((s) => s.current_user);
|
||||
}
|
||||
|
||||
// call a function, and if it errors, call addNotification('error', ...) (e.g. to show user a snackbar)
|
||||
const notifyOnError = (fn, errorToMessage) =>
|
||||
async function(...args) {
|
||||
if (typeof errorToMessage !== 'function') {
|
||||
errorToMessage = (error) => error.message;
|
||||
}
|
||||
try {
|
||||
return await fn(...args);
|
||||
} catch (error) {
|
||||
addNotification('error', errorToMessage(error));
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const addBestTag = notifyOnError(
|
||||
() =>
|
||||
addTag({
|
||||
id: comment.id,
|
||||
name: BEST_TAG,
|
||||
assetId: asset.id
|
||||
}),
|
||||
() => 'Failed to tag comment as best'
|
||||
);
|
||||
|
||||
const removeBestTag = notifyOnError(
|
||||
() =>
|
||||
removeTag({
|
||||
id: comment.id,
|
||||
name: BEST_TAG,
|
||||
assetId: asset.id
|
||||
}),
|
||||
() => 'Failed to remove best comment tag'
|
||||
);
|
||||
|
||||
/**
|
||||
* conditionClassNames
|
||||
* adds classNames based on condition
|
||||
@@ -430,133 +378,137 @@ export default class Comment extends React.Component {
|
||||
}
|
||||
);
|
||||
|
||||
// props that are passed down the slots.
|
||||
const slotProps = {
|
||||
data,
|
||||
root,
|
||||
asset,
|
||||
comment,
|
||||
depth,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={rootClassName}
|
||||
id={`c_${comment.id}`}
|
||||
>
|
||||
{!isReply && <hr aria-hidden={true} />}
|
||||
<div className={commentClassName}>
|
||||
<AuthorName author={comment.user} className={'talk-stream-comment-user-name'} />
|
||||
{isStaff(comment.tags) ? <TagLabel>Staff</TagLabel> : null}
|
||||
|
||||
{commentIsBest(comment)
|
||||
? <TagLabel><BestIndicator /></TagLabel>
|
||||
: null }
|
||||
|
||||
<span className={`${styles.bylineSecondary} talk-stream-comment-user-byline`} >
|
||||
<PubDate created_at={comment.created_at} className={'talk-stream-comment-published-date'} />
|
||||
{
|
||||
(comment.editing && comment.editing.edited)
|
||||
? <span> <span className={styles.editedMarker}>({t('comment.edited')})</span></span>
|
||||
: null
|
||||
}
|
||||
</span>
|
||||
|
||||
<Slot
|
||||
className={styles.commentInfoBar}
|
||||
fill="commentInfoBar"
|
||||
depth={depth}
|
||||
comment={comment}
|
||||
commentId={comment.id}
|
||||
data={this.props.data}
|
||||
root={this.props.root}
|
||||
className={styles.commentAvatar}
|
||||
fill="commentAvatar"
|
||||
{...slotProps}
|
||||
inline
|
||||
/>
|
||||
|
||||
{ (currentUser && (comment.user.id === currentUser.id)) &&
|
||||
<div className={styles.commentContainer}>
|
||||
|
||||
/* User can edit/delete their own comment for a short window after posting */
|
||||
<span className={cn(styles.topRight)}>
|
||||
{
|
||||
commentIsStillEditable(comment) &&
|
||||
<a
|
||||
className={cn(styles.link, {[styles.active]: this.state.isEditing})}
|
||||
onClick={this.onClickEdit}>Edit</a>
|
||||
}
|
||||
</span>
|
||||
}
|
||||
{ (currentUser && (comment.user.id !== currentUser.id)) &&
|
||||
<div className={styles.header}>
|
||||
<AuthorName author={comment.user} className={'talk-stream-comment-user-name'} />
|
||||
{isStaff(comment.tags) ? <TagLabel>Staff</TagLabel> : null}
|
||||
|
||||
/* TopRightMenu allows currentUser to ignore other users' comments */
|
||||
<span className={cn(styles.topRight, styles.topRightMenu)}>
|
||||
<TopRightMenu
|
||||
comment={comment}
|
||||
ignoreUser={ignoreUser}
|
||||
addNotification={addNotification} />
|
||||
<span className={`${styles.bylineSecondary} talk-stream-comment-user-byline`} >
|
||||
<PubDate created_at={comment.created_at} className={'talk-stream-comment-published-date'} />
|
||||
{
|
||||
(comment.editing && comment.editing.edited)
|
||||
? <span> <span className={styles.editedMarker}>({t('comment.edited')})</span></span>
|
||||
: null
|
||||
}
|
||||
</span>
|
||||
}
|
||||
{
|
||||
this.state.isEditing
|
||||
? <EditableCommentContent
|
||||
editComment={this.editComment}
|
||||
addNotification={addNotification}
|
||||
asset={asset}
|
||||
comment={comment}
|
||||
currentUser={currentUser}
|
||||
maxCharCount={maxCharCount}
|
||||
parentId={parentId}
|
||||
stopEditing={this.stopEditing}
|
||||
/>
|
||||
: <div>
|
||||
<Slot fill="commentContent" comment={comment} defaultComponent={CommentContent} />
|
||||
</div>
|
||||
}
|
||||
|
||||
<div className="commentActionsLeft comment__action-container">
|
||||
<Slot
|
||||
fill="commentReactions"
|
||||
data={this.props.data}
|
||||
root={this.props.root}
|
||||
comment={comment}
|
||||
commentId={comment.id}
|
||||
inline
|
||||
/>
|
||||
<ActionButton>
|
||||
<IfUserCanModifyBest user={currentUser}>
|
||||
<BestButton
|
||||
isBest={commentIsBest(comment)}
|
||||
addBest={addBestTag}
|
||||
removeBest={removeBestTag}
|
||||
/>
|
||||
</IfUserCanModifyBest>
|
||||
</ActionButton>
|
||||
{!disableReply &&
|
||||
<ActionButton>
|
||||
<ReplyButton
|
||||
onClick={this.showReplyBox}
|
||||
parentCommentId={parentId || comment.id}
|
||||
currentUserId={currentUser && currentUser.id}
|
||||
/>
|
||||
</ActionButton>}
|
||||
</div>
|
||||
<div className="commentActionsRight comment__action-container">
|
||||
<Slot
|
||||
fill="commentActions"
|
||||
wrapperComponent={ActionButton}
|
||||
data={this.props.data}
|
||||
root={this.props.root}
|
||||
asset={asset}
|
||||
comment={comment}
|
||||
commentId={comment.id}
|
||||
inline
|
||||
/>
|
||||
<ActionButton>
|
||||
<FlagComment
|
||||
flaggedByCurrentUser={!!myFlag}
|
||||
flag={myFlag}
|
||||
id={comment.id}
|
||||
author_id={comment.user.id}
|
||||
postFlag={postFlag}
|
||||
addNotification={addNotification}
|
||||
postDontAgree={postDontAgree}
|
||||
deleteAction={deleteAction}
|
||||
showSignInDialog={showSignInDialog}
|
||||
currentUser={currentUser}
|
||||
<Slot
|
||||
className={styles.commentInfoBar}
|
||||
fill="commentInfoBar"
|
||||
{...slotProps}
|
||||
/>
|
||||
</ActionButton>
|
||||
|
||||
{ (currentUser && (comment.user.id === currentUser.id)) &&
|
||||
|
||||
/* User can edit/delete their own comment for a short window after posting */
|
||||
<span className={cn(styles.topRight)}>
|
||||
{
|
||||
commentIsStillEditable(comment) &&
|
||||
<a
|
||||
className={cn(styles.link, {[styles.active]: this.state.isEditing})}
|
||||
onClick={this.onClickEdit}>Edit</a>
|
||||
}
|
||||
</span>
|
||||
}
|
||||
{ (currentUser && (comment.user.id !== currentUser.id)) &&
|
||||
|
||||
/* TopRightMenu allows currentUser to ignore other users' comments */
|
||||
<span className={cn(styles.topRight, styles.topRightMenu)}>
|
||||
<TopRightMenu
|
||||
comment={comment}
|
||||
ignoreUser={ignoreUser}
|
||||
addNotification={addNotification} />
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
<div className={styles.content}>
|
||||
{
|
||||
this.state.isEditing
|
||||
? <EditableCommentContent
|
||||
editComment={this.editComment}
|
||||
addNotification={addNotification}
|
||||
comment={comment}
|
||||
currentUser={currentUser}
|
||||
charCountEnable={charCountEnable}
|
||||
maxCharCount={maxCharCount}
|
||||
parentId={parentId}
|
||||
stopEditing={this.stopEditing}
|
||||
/>
|
||||
: <div>
|
||||
<Slot
|
||||
fill="commentContent"
|
||||
defaultComponent={CommentContent}
|
||||
{...slotProps}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div className={styles.footer}>
|
||||
<div className="commentActionsLeft comment__action-container">
|
||||
<Slot
|
||||
fill="commentReactions"
|
||||
{...slotProps}
|
||||
inline
|
||||
/>
|
||||
{!disableReply &&
|
||||
<ActionButton>
|
||||
<ReplyButton
|
||||
onClick={this.showReplyBox}
|
||||
parentCommentId={parentId || comment.id}
|
||||
currentUserId={currentUser && currentUser.id}
|
||||
/>
|
||||
</ActionButton>}
|
||||
</div>
|
||||
<div className="commentActionsRight comment__action-container">
|
||||
<Slot
|
||||
fill="commentActions"
|
||||
wrapperComponent={ActionButton}
|
||||
{...slotProps}
|
||||
inline
|
||||
/>
|
||||
<ActionButton>
|
||||
<FlagComment
|
||||
flaggedByCurrentUser={!!myFlag}
|
||||
flag={myFlag}
|
||||
id={comment.id}
|
||||
author_id={comment.user.id}
|
||||
postFlag={postFlag}
|
||||
addNotification={addNotification}
|
||||
postDontAgree={postDontAgree}
|
||||
deleteAction={deleteAction}
|
||||
showSignInDialog={showSignInDialog}
|
||||
currentUser={currentUser}
|
||||
/>
|
||||
</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{activeReplyBox === comment.id
|
||||
? <ReplyBox
|
||||
commentPostedHandler={() => {
|
||||
@@ -593,8 +545,6 @@ export default class Comment extends React.Component {
|
||||
currentUser={currentUser}
|
||||
postFlag={postFlag}
|
||||
deleteAction={deleteAction}
|
||||
addTag={addTag}
|
||||
removeTag={removeTag}
|
||||
loadMore={loadMore}
|
||||
ignoreUser={ignoreUser}
|
||||
charCountEnable={charCountEnable}
|
||||
|
||||
@@ -18,11 +18,6 @@ export class EditableCommentContent extends React.Component {
|
||||
|
||||
// show notification to the user (e.g. for errors)
|
||||
addNotification: PropTypes.func.isRequired,
|
||||
asset: PropTypes.shape({
|
||||
settings: PropTypes.shape({
|
||||
charCountEnable: PropTypes.bool,
|
||||
}),
|
||||
}).isRequired,
|
||||
|
||||
// comment that is being edited
|
||||
comment: PropTypes.shape({
|
||||
@@ -39,6 +34,7 @@ export class EditableCommentContent extends React.Component {
|
||||
currentUser: PropTypes.shape({
|
||||
id: PropTypes.string.isRequired
|
||||
}),
|
||||
charCountEnable: PropTypes.bool,
|
||||
maxCharCount: PropTypes.number,
|
||||
|
||||
// edit a comment, passed {{ body }}
|
||||
@@ -121,7 +117,7 @@ export class EditableCommentContent extends React.Component {
|
||||
<div className={styles.editCommentForm}>
|
||||
<CommentForm
|
||||
defaultValue={this.props.comment.body}
|
||||
charCountEnable={this.props.asset.settings.charCountEnable}
|
||||
charCountEnable={this.props.charCountEnable}
|
||||
maxCharCount={this.props.maxCharCount}
|
||||
submitEnabled={this.isSubmitEnabled}
|
||||
body={this.state.body}
|
||||
@@ -149,7 +145,7 @@ export class EditableCommentContent extends React.Component {
|
||||
}
|
||||
</span>
|
||||
: <span>
|
||||
<Icon name="timer"/> {t('edit_comment.edit_window_timer_prefix')}
|
||||
<Icon name="timer" className={styles.timerIcon}/> {t('edit_comment.edit_window_timer_prefix')}
|
||||
<CountdownSeconds
|
||||
until={this.getEditableUntil()}
|
||||
classNameForMsRemaining={(remainingMs) => (remainingMs <= 10 * 1000) ? styles.editWindowAlmostOver : '' }
|
||||
|
||||
@@ -4,67 +4,82 @@ import Slot from 'coral-framework/components/Slot';
|
||||
import {can} from 'coral-framework/services/perms';
|
||||
import t from 'coral-framework/services/i18n';
|
||||
|
||||
import {TabBar, Tab, TabContent, Button} from 'coral-ui';
|
||||
import Count from 'coral-plugin-comment-count/CommentCount';
|
||||
import {TabBar, Tab, TabContent, TabPane} from 'coral-ui';
|
||||
import ProfileContainer from 'coral-settings/containers/ProfileContainer';
|
||||
import Popup from 'coral-framework/components/Popup';
|
||||
import IfSlotIsNotEmpty from 'coral-framework/components/IfSlotIsNotEmpty';
|
||||
import ConfigureStreamContainer
|
||||
from 'coral-configure/containers/ConfigureStreamContainer';
|
||||
import cn from 'classnames';
|
||||
|
||||
export default class Embed extends React.Component {
|
||||
changeTab = (tab) => {
|
||||
switch (tab) {
|
||||
case 0:
|
||||
this.props.setActiveTab('stream');
|
||||
break;
|
||||
case 1:
|
||||
this.props.setActiveTab('profile');
|
||||
|
||||
// TODO: move data fetching to profile container.
|
||||
// TODO: move data fetching to appropiate containers.
|
||||
switch (tab) {
|
||||
case 'profile':
|
||||
this.props.data.refetch();
|
||||
break;
|
||||
case 2:
|
||||
this.props.setActiveTab('config');
|
||||
|
||||
// TODO: move data fetching to config container.
|
||||
case 'config':
|
||||
this.props.data.refetch();
|
||||
break;
|
||||
}
|
||||
this.props.setActiveTab(tab);
|
||||
};
|
||||
|
||||
handleShowProfile = () => this.props.setActiveTab('profile');
|
||||
|
||||
render() {
|
||||
const {activeTab, viewAllComments, commentId} = this.props;
|
||||
const {asset: {totalCommentCount}} = this.props.root;
|
||||
const {activeTab, commentId, auth: {showSignInDialog, signInDialogFocus}, blurSignInDialog, focusSignInDialog, hideSignInDialog} = this.props;
|
||||
const {user} = this.props.auth;
|
||||
const hasHighlightedComment = !!commentId;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="commentStream">
|
||||
<TabBar onChange={this.changeTab} activeTab={activeTab} className='talk-stream-tabbar'>
|
||||
<Tab className={'talk-stream-comment-count-tab'} id='stream'><Count count={totalCommentCount}/></Tab>
|
||||
<Tab className={'talk-stream-profile-tab'} id='profile'>{t('framework.my_profile')}</Tab>
|
||||
<Tab className={'talk-stream-configuration-tab'} id='config' restricted={!can(user, 'UPDATE_CONFIG')}>{t('framework.configure_stream')}</Tab>
|
||||
</TabBar>
|
||||
{commentId &&
|
||||
<Button
|
||||
cStyle="darkGrey"
|
||||
style={{float: 'right'}}
|
||||
onClick={viewAllComments}
|
||||
>
|
||||
{t('framework.show_all_comments')}
|
||||
</Button>}
|
||||
<Slot fill="embed" />
|
||||
<TabContent show={activeTab === 'stream'}>
|
||||
<div className={cn('talk-embed-stream', {'talk-embed-stream-highlight-comment': hasHighlightedComment})}>
|
||||
<IfSlotIsNotEmpty slot="login">
|
||||
<Popup
|
||||
href='/embed/stream/login'
|
||||
title='Login'
|
||||
features='menubar=0,resizable=0,width=500,height=550,top=200,left=500'
|
||||
open={showSignInDialog}
|
||||
focus={signInDialogFocus}
|
||||
onFocus={focusSignInDialog}
|
||||
onBlur={blurSignInDialog}
|
||||
onClose={hideSignInDialog}
|
||||
/>
|
||||
</IfSlotIsNotEmpty>
|
||||
<TabBar
|
||||
onTabClick={this.changeTab}
|
||||
activeTab={activeTab}
|
||||
className='talk-embed-stream-tab-bar'
|
||||
aria-controls='talk-embed-stream-tab-content'
|
||||
>
|
||||
<Tab tabId={'stream'} className={'talk-embed-stream-comments-tab'}>
|
||||
{t('embed_comments_tab')}
|
||||
</Tab>
|
||||
<Tab tabId={'profile'} className={'talk-embed-stream-profile-tab'}>
|
||||
{t('framework.my_profile')}
|
||||
</Tab>
|
||||
{can(user, 'UPDATE_CONFIG') &&
|
||||
<Tab tabId={'config'} className={'talk-embed-stream-configuration-tab'}>
|
||||
{t('framework.configure_stream')}
|
||||
</Tab>
|
||||
}
|
||||
</TabBar>
|
||||
<Slot fill="embed" />
|
||||
|
||||
<TabContent
|
||||
activeTab={activeTab}
|
||||
id='talk-embed-stream-tab-content'
|
||||
>
|
||||
<TabPane tabId={'stream'}>
|
||||
<Stream data={this.props.data} root={this.props.root} />
|
||||
</TabContent>
|
||||
<TabContent show={activeTab === 'profile'}>
|
||||
</TabPane>
|
||||
<TabPane tabId={'profile'}>
|
||||
<ProfileContainer />
|
||||
</TabContent>
|
||||
<TabContent show={activeTab === 'config'}>
|
||||
</TabPane>
|
||||
<TabPane tabId={'config'}>
|
||||
<ConfigureStreamContainer />
|
||||
</TabContent>
|
||||
</div>
|
||||
</TabPane>
|
||||
</TabContent>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, {PropTypes} from 'react';
|
||||
import {Button} from 'coral-ui';
|
||||
|
||||
import t from 'coral-framework/services/i18n';
|
||||
|
||||
@@ -6,11 +7,11 @@ const NewCount = ({count, loadMore}) => {
|
||||
return <div className='talk-new-comments talk-load-more'>
|
||||
{
|
||||
count ?
|
||||
<button onClick={loadMore}>
|
||||
<Button onClick={loadMore}>
|
||||
{count === 1
|
||||
? t('framework.new_count', count, t('framework.comment'))
|
||||
: t('framework.new_count', count, t('framework.comments'))}
|
||||
</button>
|
||||
</Button>
|
||||
: null
|
||||
}
|
||||
</div>;
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
.root {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.viewAllButton {
|
||||
position: absolute;
|
||||
right: 0px;
|
||||
}
|
||||
|
||||
.tabPanel {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.filterWrapper {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.highlightedContainer {
|
||||
margin-top: 28px;
|
||||
}
|
||||
|
||||
.tabContainer {
|
||||
position: relative;
|
||||
margin-top: 28px;
|
||||
padding-bottom: 50px;
|
||||
min-height: 600px;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, {PropTypes} from 'react';
|
||||
import LoadMore from './LoadMore';
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {StreamError} from './StreamError';
|
||||
import Comment from '../components/Comment';
|
||||
import SuspendedAccount from './SuspendedAccount';
|
||||
@@ -10,163 +10,96 @@ import {ModerationLink} from 'coral-plugin-moderation';
|
||||
import RestrictedMessageBox
|
||||
from 'coral-framework/components/RestrictedMessageBox';
|
||||
import t, {timeago} from 'coral-framework/services/i18n';
|
||||
import {getSlotComponents} from 'coral-framework/helpers/plugins';
|
||||
import CommentBox from 'coral-plugin-commentbox/CommentBox';
|
||||
import QuestionBox from 'coral-plugin-questionbox/QuestionBox';
|
||||
import IgnoredCommentTombstone from './IgnoredCommentTombstone';
|
||||
import NewCount from './NewCount';
|
||||
import {TransitionGroup} from 'react-transition-group';
|
||||
import {forEachError} from 'coral-framework/utils';
|
||||
import {Button, TabBar, Tab, TabCount, TabContent, TabPane} from 'coral-ui';
|
||||
import cn from 'classnames';
|
||||
|
||||
import {getTopLevelParent} from '../graphql/utils';
|
||||
import AllCommentsPane from './AllCommentsPane';
|
||||
|
||||
const hasComment = (nodes, id) => nodes.some((node) => node.id === id);
|
||||
|
||||
// resetCursors will return the id cursors of the first and second comment of
|
||||
// the current comment list. The cursors are used to dertermine which
|
||||
// comments to show. The spare cursor functions as a backup in case one
|
||||
// of the comments gets deleted.
|
||||
function resetCursors(state, props) {
|
||||
const comments = props.root.asset.comments;
|
||||
if (comments && comments.nodes.length) {
|
||||
const idCursors = [comments.nodes[0].id];
|
||||
if (comments.nodes[1]) {
|
||||
idCursors.push(comments.nodes[1].id);
|
||||
}
|
||||
return {idCursors};
|
||||
}
|
||||
return {idCursors: []};
|
||||
}
|
||||
|
||||
// invalidateCursor is called whenever a comment is removed which is referenced
|
||||
// by one of the 2 id cursors. It returns a new set of id cursors calculated
|
||||
// using the help of the backup cursor.
|
||||
function invalidateCursor(invalidated, state, props) {
|
||||
const alt = invalidated === 1 ? 0 : 1;
|
||||
const comments = props.root.asset.comments;
|
||||
const idCursors = [];
|
||||
if (state.idCursors[alt]) {
|
||||
idCursors.push(state.idCursors[alt]);
|
||||
const index = comments.nodes.findIndex((node) => node.id === idCursors[0]);
|
||||
const nextInLine = comments.nodes[index + 1];
|
||||
if (nextInLine) {
|
||||
idCursors.push(nextInLine.id);
|
||||
}
|
||||
}
|
||||
return {idCursors};
|
||||
}
|
||||
import styles from './Stream.css';
|
||||
|
||||
class Stream extends React.Component {
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
...resetCursors(this.state, props),
|
||||
keepCommentBox: false,
|
||||
loadingState: '',
|
||||
};
|
||||
}
|
||||
|
||||
componentWillReceiveProps(next) {
|
||||
const {root: {asset: {comments: prevComments}}} = this.props;
|
||||
const {root: {asset: {comments: nextComments}}} = next;
|
||||
|
||||
if (!prevComments && nextComments) {
|
||||
this.setState(resetCursors);
|
||||
return;
|
||||
}
|
||||
|
||||
// Keep comment box when user was live suspended, banned, ...
|
||||
if (!this.userIsDegraged(this.props) && this.userIsDegraged(next)) {
|
||||
this.setState({keepCommentBox: true});
|
||||
}
|
||||
|
||||
if (
|
||||
prevComments && nextComments &&
|
||||
nextComments.nodes.length < prevComments.nodes.length
|
||||
) {
|
||||
this.fallbackAllTab(next);
|
||||
}
|
||||
|
||||
// Invalidate first cursor if referenced comment was removed.
|
||||
if (this.state.idCursors[0] && !hasComment(nextComments.nodes, this.state.idCursors[0])) {
|
||||
this.setState(invalidateCursor(0, this.state, next));
|
||||
}
|
||||
componentDidMount() {
|
||||
this.fallbackAllTab();
|
||||
}
|
||||
|
||||
// Invalidate second cursor if referenced comment was removed.
|
||||
if (this.state.idCursors[1] && !hasComment(nextComments.nodes, this.state.idCursors[1])) {
|
||||
this.setState(invalidateCursor(1, this.state, next));
|
||||
fallbackAllTab(props = this.props) {
|
||||
if (props.activeStreamTab !== 'all') {
|
||||
const slotPlugins = this.getSlotComponents('streamTabs', props).map((c) => c.talkPluginName);
|
||||
if (slotPlugins.indexOf(props.activeStreamTab) === -1) {
|
||||
props.setActiveStreamTab('all');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
viewNewComments = () => {
|
||||
this.setState(resetCursors);
|
||||
};
|
||||
getSlotProps({data, root, root: {asset}} = this.props) {
|
||||
return {data, root, asset};
|
||||
}
|
||||
|
||||
setActiveReplyBox = (reactKey) => {
|
||||
getSlotComponents(slot, props = this.props) {
|
||||
return getSlotComponents(slot, props.reduxState, this.getSlotProps(props));
|
||||
}
|
||||
|
||||
setActiveReplyBox = (id) => {
|
||||
if (!this.props.auth.user) {
|
||||
this.props.showSignInDialog();
|
||||
} else {
|
||||
this.props.setActiveReplyBox(reactKey);
|
||||
this.props.setActiveReplyBox(id);
|
||||
}
|
||||
};
|
||||
|
||||
loadMoreComments = () => {
|
||||
this.setState({loadingState: 'loading'});
|
||||
this.props.loadMoreComments()
|
||||
.then(() => {
|
||||
this.setState({loadingState: 'success'});
|
||||
})
|
||||
.catch((error) => {
|
||||
this.setState({loadingState: 'error'});
|
||||
forEachError(error, ({msg}) => {this.props.addNotification('error', msg);});
|
||||
});
|
||||
}
|
||||
|
||||
// getVisibileComments returns a list containing comments
|
||||
// which were authored by current user or comes after the `idCursor`.
|
||||
getVisibleComments() {
|
||||
const {root: {asset: {comments}}, auth: {user}} = this.props;
|
||||
const idCursor = this.state.idCursors[0];
|
||||
const userId = user ? user.id : null;
|
||||
|
||||
if (!comments) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const view = [];
|
||||
let pastCursor = false;
|
||||
comments.nodes.forEach((comment) => {
|
||||
if (comment.id === idCursor) {
|
||||
pastCursor = true;
|
||||
}
|
||||
if (pastCursor || comment.user.id === userId) {
|
||||
view.push(comment);
|
||||
}
|
||||
});
|
||||
return view;
|
||||
}
|
||||
|
||||
userIsDegraged({auth: {user}} = this.props) {
|
||||
return !can(user, 'INTERACT_WITH_COMMUNITY');
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
data,
|
||||
root,
|
||||
activeReplyBox,
|
||||
setActiveReplyBox,
|
||||
appendItemArray,
|
||||
commentClassNames,
|
||||
root: {asset, asset: {comments}, comment, me},
|
||||
root: {asset, asset: {comments, totalCommentCount}, comment, me},
|
||||
postComment,
|
||||
addNotification,
|
||||
editComment,
|
||||
postFlag,
|
||||
postDontAgree,
|
||||
deleteAction,
|
||||
showSignInDialog,
|
||||
addTag,
|
||||
updateItem,
|
||||
ignoreUser,
|
||||
activeStreamTab,
|
||||
setActiveStreamTab,
|
||||
loadNewReplies,
|
||||
loadMoreComments,
|
||||
viewAllComments,
|
||||
auth: {loggedIn, user},
|
||||
removeTag,
|
||||
pluginProps,
|
||||
editName
|
||||
} = this.props;
|
||||
const {keepCommentBox, loadingState} = this.state;
|
||||
const view = this.getVisibleComments();
|
||||
const {keepCommentBox} = this.state;
|
||||
const open = asset.closedAt === null;
|
||||
|
||||
// even though the permalinked comment is the highlighted one, we're displaying its parent + replies
|
||||
@@ -187,6 +120,7 @@ class Stream extends React.Component {
|
||||
};
|
||||
|
||||
const showCommentBox = loggedIn && ((!banned && !temporarilySuspended && !highlightedComment) || keepCommentBox);
|
||||
const slotProps = this.getSlotProps();
|
||||
|
||||
if (!comment && !comments) {
|
||||
console.error('Talk: No comments came back from the graph given that query. Please, check the query params.');
|
||||
@@ -194,7 +128,15 @@ class Stream extends React.Component {
|
||||
}
|
||||
|
||||
return (
|
||||
<div id="stream">
|
||||
<div id="stream" className={styles.root}>
|
||||
{comment &&
|
||||
<Button
|
||||
cStyle="darkGrey"
|
||||
className={cn('talk-stream-show-all-comments-button', styles.viewAllButton)}
|
||||
onClick={viewAllComments}
|
||||
>
|
||||
{t('framework.show_all_comments')}
|
||||
</Button>}
|
||||
|
||||
{open
|
||||
? <div id="commentBox">
|
||||
@@ -211,7 +153,7 @@ class Stream extends React.Component {
|
||||
<RestrictedMessageBox>
|
||||
{t(
|
||||
'stream.temporarily_suspended',
|
||||
this.props.root.settings.organizationName,
|
||||
root.settings.organizationName,
|
||||
timeago(user.suspension.until)
|
||||
)}
|
||||
</RestrictedMessageBox>}
|
||||
@@ -223,10 +165,10 @@ class Stream extends React.Component {
|
||||
/>}
|
||||
{showCommentBox &&
|
||||
<CommentBox
|
||||
addNotification={this.props.addNotification}
|
||||
postComment={this.props.postComment}
|
||||
appendItemArray={this.props.appendItemArray}
|
||||
updateItem={this.props.updateItem}
|
||||
addNotification={addNotification}
|
||||
postComment={postComment}
|
||||
appendItemArray={appendItemArray}
|
||||
updateItem={updateItem}
|
||||
assetId={asset.id}
|
||||
premod={asset.settings.moderation}
|
||||
isReply={false}
|
||||
@@ -237,7 +179,10 @@ class Stream extends React.Component {
|
||||
</div>
|
||||
: <p>{asset.settings.closedMessage}</p>}
|
||||
|
||||
<Slot fill="stream" />
|
||||
<Slot
|
||||
fill="stream"
|
||||
{...slotProps}
|
||||
/>
|
||||
|
||||
{loggedIn && (
|
||||
<ModerationLink
|
||||
@@ -246,86 +191,98 @@ class Stream extends React.Component {
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="talk-stream-wrapper-box">
|
||||
<Slot fill="streamBox" />
|
||||
</div>
|
||||
|
||||
{/* the highlightedComment is isolated after the user followed a permalink */}
|
||||
{highlightedComment
|
||||
? <Comment
|
||||
data={this.props.data}
|
||||
root={this.props.root}
|
||||
setActiveReplyBox={this.setActiveReplyBox}
|
||||
activeReplyBox={this.props.activeReplyBox}
|
||||
addNotification={addNotification}
|
||||
depth={0}
|
||||
disableReply={!open}
|
||||
postComment={this.props.postComment}
|
||||
asset={asset}
|
||||
currentUser={user}
|
||||
highlighted={comment.id}
|
||||
postFlag={this.props.postFlag}
|
||||
postDontAgree={this.props.postDontAgree}
|
||||
loadMore={this.props.loadNewReplies}
|
||||
deleteAction={this.props.deleteAction}
|
||||
showSignInDialog={this.props.showSignInDialog}
|
||||
key={highlightedComment.id}
|
||||
commentIsIgnored={commentIsIgnored}
|
||||
reactKey={highlightedComment.id}
|
||||
comment={highlightedComment}
|
||||
charCountEnable={asset.settings.charCountEnable}
|
||||
maxCharCount={asset.settings.charCount}
|
||||
editComment={this.props.editComment}
|
||||
liveUpdates={true}
|
||||
/>
|
||||
: <div className="talk-stream-comments-container">
|
||||
<NewCount
|
||||
count={comments.nodes.length - view.length}
|
||||
loadMore={this.viewNewComments}
|
||||
? (
|
||||
<div className={cn('talk-stream-highlighted-container', styles.highlightedContainer)}>
|
||||
<Comment
|
||||
data={data}
|
||||
root={root}
|
||||
commentClassNames={commentClassNames}
|
||||
ignoreUser={ignoreUser}
|
||||
setActiveReplyBox={setActiveReplyBox}
|
||||
activeReplyBox={activeReplyBox}
|
||||
addNotification={addNotification}
|
||||
depth={0}
|
||||
disableReply={!open}
|
||||
postComment={postComment}
|
||||
asset={asset}
|
||||
currentUser={user}
|
||||
highlighted={comment.id}
|
||||
postFlag={postFlag}
|
||||
postDontAgree={postDontAgree}
|
||||
loadMore={loadNewReplies}
|
||||
deleteAction={deleteAction}
|
||||
showSignInDialog={showSignInDialog}
|
||||
key={highlightedComment.id}
|
||||
commentIsIgnored={commentIsIgnored}
|
||||
comment={highlightedComment}
|
||||
charCountEnable={asset.settings.charCountEnable}
|
||||
maxCharCount={asset.settings.charCount}
|
||||
editComment={editComment}
|
||||
liveUpdates={true}
|
||||
/>
|
||||
<TransitionGroup component='div' className="embed__stream">
|
||||
{view.map((comment) => {
|
||||
return commentIsIgnored(comment)
|
||||
? <IgnoredCommentTombstone key={comment.id} />
|
||||
: <Comment
|
||||
commentClassNames={commentClassNames}
|
||||
data={this.props.data}
|
||||
root={this.props.root}
|
||||
disableReply={!open}
|
||||
setActiveReplyBox={this.setActiveReplyBox}
|
||||
activeReplyBox={this.props.activeReplyBox}
|
||||
addNotification={addNotification}
|
||||
depth={0}
|
||||
postComment={postComment}
|
||||
asset={asset}
|
||||
currentUser={user}
|
||||
postFlag={postFlag}
|
||||
postDontAgree={postDontAgree}
|
||||
addTag={addTag}
|
||||
removeTag={removeTag}
|
||||
ignoreUser={ignoreUser}
|
||||
commentIsIgnored={commentIsIgnored}
|
||||
loadMore={this.props.loadNewReplies}
|
||||
deleteAction={deleteAction}
|
||||
showSignInDialog={showSignInDialog}
|
||||
key={comment.id}
|
||||
reactKey={comment.id}
|
||||
comment={comment}
|
||||
pluginProps={pluginProps}
|
||||
charCountEnable={asset.settings.charCountEnable}
|
||||
maxCharCount={asset.settings.charCount}
|
||||
editComment={this.props.editComment}
|
||||
liveUpdates={false}
|
||||
/>;
|
||||
})}
|
||||
</TransitionGroup>
|
||||
<LoadMore
|
||||
topLevel={true}
|
||||
moreComments={asset.comments.hasNextPage}
|
||||
loadMore={this.loadMoreComments}
|
||||
loadingState={loadingState}
|
||||
/>
|
||||
</div>}
|
||||
</div>
|
||||
)
|
||||
: <div className={cn('talk-stream-tab-container', styles.tabContainer)}>
|
||||
<div
|
||||
className={cn('talk-stream-filter-wrapper', styles.filterWrapper)}
|
||||
>
|
||||
<Slot
|
||||
fill="streamFilter"
|
||||
{...slotProps}
|
||||
/>
|
||||
</div>
|
||||
<TabBar activeTab={activeStreamTab} onTabClick={setActiveStreamTab} sub>
|
||||
{this.getSlotComponents('streamTabs').map((PluginComponent) => (
|
||||
<Tab tabId={PluginComponent.talkPluginName} key={PluginComponent.talkPluginName}>
|
||||
<PluginComponent
|
||||
{...slotProps}
|
||||
active={activeStreamTab === PluginComponent.talkPluginName}
|
||||
/>
|
||||
</Tab>
|
||||
))}
|
||||
<Tab tabId={'all'}>
|
||||
All Comments <TabCount active={activeStreamTab === 'all'} sub>{totalCommentCount}</TabCount>
|
||||
</Tab>
|
||||
</TabBar>
|
||||
<TabContent activeTab={activeStreamTab} sub>
|
||||
{this.getSlotComponents('streamTabPanes').map((PluginComponent) => (
|
||||
<TabPane tabId={PluginComponent.talkPluginName} key={PluginComponent.talkPluginName}>
|
||||
<PluginComponent
|
||||
{...slotProps}
|
||||
/>
|
||||
</TabPane>
|
||||
))}
|
||||
<TabPane tabId={'all'}>
|
||||
<AllCommentsPane
|
||||
data={data}
|
||||
root={root}
|
||||
comments={comments}
|
||||
commentClassNames={commentClassNames}
|
||||
ignoreUser={ignoreUser}
|
||||
setActiveReplyBox={setActiveReplyBox}
|
||||
activeReplyBox={activeReplyBox}
|
||||
addNotification={addNotification}
|
||||
disableReply={!open}
|
||||
postComment={postComment}
|
||||
asset={asset}
|
||||
currentUser={user}
|
||||
postFlag={postFlag}
|
||||
postDontAgree={postDontAgree}
|
||||
loadMore={loadMoreComments}
|
||||
loadNewReplies={loadNewReplies}
|
||||
deleteAction={deleteAction}
|
||||
showSignInDialog={showSignInDialog}
|
||||
commentIsIgnored={commentIsIgnored}
|
||||
charCountEnable={asset.settings.charCountEnable}
|
||||
maxCharCount={asset.settings.charCount}
|
||||
editComment={editComment}
|
||||
/>
|
||||
</TabPane>
|
||||
</TabContent>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -335,12 +292,6 @@ Stream.propTypes = {
|
||||
addNotification: PropTypes.func.isRequired,
|
||||
postComment: PropTypes.func.isRequired,
|
||||
|
||||
// dispatch action to add a tag to a comment
|
||||
addTag: PropTypes.func,
|
||||
|
||||
// dispatch action to remove a tag from a comment
|
||||
removeTag: PropTypes.func,
|
||||
|
||||
// dispatch action to ignore another user
|
||||
ignoreUser: React.PropTypes.func,
|
||||
|
||||
|
||||
@@ -2,21 +2,25 @@
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/**
|
||||
.toggler {
|
||||
composes: buttonReset from "coral-framework/styles/reset.css";
|
||||
}
|
||||
|
||||
/**
|
||||
* Up/Down Chevrons for the top right menu
|
||||
*/
|
||||
.chevron {
|
||||
}
|
||||
.chevron:before {
|
||||
content: '⌃';
|
||||
display: inline-block;
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
top: 0.25em;
|
||||
top: 0.25em;
|
||||
}
|
||||
|
||||
/* Down Arrow */
|
||||
/* Down Arrow */
|
||||
.chevron.down:before {
|
||||
display: inline-block;
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
transform: rotate(180deg);
|
||||
top: 0;
|
||||
|
||||
@@ -27,9 +27,8 @@ export default class Toggleable extends React.Component {
|
||||
const {isOpen} = this.state;
|
||||
return (
|
||||
<ClickOutside onClickOutside={this.close}>
|
||||
<span className={styles.Toggleable} tabIndex="0" >
|
||||
<span className={styles.toggler}
|
||||
onClick={this.toggle}>{isOpen ? upArrow : downArrow}</span>
|
||||
<span className={styles.Toggleable}>
|
||||
<button className={styles.toggler} onClick={this.toggle}>{isOpen ? upArrow : downArrow}</button>
|
||||
{isOpen ? children : null}
|
||||
</span>
|
||||
</ClickOutside>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
export const SET_ACTIVE_REPLY_BOX = 'SET_ACTIVE_REPLY_BOX';
|
||||
export const ADDTL_COMMENTS_ON_LOAD_MORE = 10;
|
||||
export const VIEW_ALL_COMMENTS = 'VIEW_ALL_COMMENTS';
|
||||
export const VIEW_COMMENT = 'VIEW_COMMENT';
|
||||
export const ADD_COMMENT_CLASSNAME = 'ADD_COMMENT_CLASSNAME';
|
||||
export const REMOVE_COMMENT_CLASSNAME = 'REMOVE_COMMENT_CLASSNAME';
|
||||
export const THREADING_LEVEL = process.env.TALK_THREADING_LEVEL;
|
||||
export const SET_ACTIVE_TAB = 'CORAL_STREAM_SET_ACTIVE_TAB';
|
||||
|
||||
@@ -1,25 +1,31 @@
|
||||
import {gql} from 'react-apollo';
|
||||
import Comment from '../components/Comment';
|
||||
import {withFragments} from 'coral-framework/hocs';
|
||||
import {getSlotsFragments} from 'coral-framework/helpers/plugins';
|
||||
import {getSlotFragmentSpreads} from 'coral-framework/utils';
|
||||
|
||||
const pluginFragments = getSlotsFragments([
|
||||
const slots = [
|
||||
'streamQuestionArea',
|
||||
'commentInputArea',
|
||||
'commentInputDetailArea',
|
||||
'commentInfoBar',
|
||||
'commentActions',
|
||||
'commentContent',
|
||||
'commentReactions'
|
||||
]);
|
||||
'commentReactions',
|
||||
'commentAvatar'
|
||||
];
|
||||
|
||||
export default withFragments({
|
||||
root: gql`
|
||||
fragment CoralEmbedStream_Comment_root on RootQuery {
|
||||
__typename
|
||||
${pluginFragments.spreads('root')}
|
||||
${getSlotFragmentSpreads(slots, 'root')}
|
||||
}
|
||||
`,
|
||||
asset: gql`
|
||||
fragment CoralEmbedStream_Comment_asset on Asset {
|
||||
__typename
|
||||
${getSlotFragmentSpreads(slots, 'asset')}
|
||||
}
|
||||
${pluginFragments.definitions('root')}
|
||||
`,
|
||||
comment: gql`
|
||||
fragment CoralEmbedStream_Comment_comment on Comment {
|
||||
@@ -27,6 +33,7 @@ export default withFragments({
|
||||
body
|
||||
created_at
|
||||
status
|
||||
replyCount
|
||||
tags {
|
||||
tag {
|
||||
name
|
||||
@@ -47,8 +54,7 @@ export default withFragments({
|
||||
edited
|
||||
editableUntil
|
||||
}
|
||||
${pluginFragments.spreads('comment')}
|
||||
${getSlotFragmentSpreads(slots, 'comment')}
|
||||
}
|
||||
${pluginFragments.definitions('comment')}
|
||||
`
|
||||
})(Comment);
|
||||
|
||||
@@ -18,9 +18,8 @@ import {addNotification} from 'coral-framework/actions/notification';
|
||||
import t from 'coral-framework/services/i18n';
|
||||
|
||||
import {setActiveTab} from '../actions/embed';
|
||||
import {viewAllComments} from '../actions/stream';
|
||||
|
||||
const {logout, checkLogin} = authActions;
|
||||
const {logout, checkLogin, focusSignInDialog, blurSignInDialog, hideSignInDialog} = authActions;
|
||||
const {fetchAssetSuccess} = assetActions;
|
||||
|
||||
class EmbedContainer extends React.Component {
|
||||
@@ -95,7 +94,7 @@ class EmbedContainer extends React.Component {
|
||||
if (!prevProps.root.comment && this.props.root.comment) {
|
||||
|
||||
// Scroll to a permalinked comment if one is in the URL once the page is done rendering.
|
||||
setTimeout(() => pym.scrollParentToChildEl('coralStream'), 0);
|
||||
setTimeout(() => pym.scrollParentToChildEl('talk-embed-stream-container'), 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,9 +147,6 @@ const USERNAME_REJECTED_SUBSCRIPTION = gql`
|
||||
|
||||
const EMBED_QUERY = gql`
|
||||
query CoralEmbedStream_Embed($assetId: ID, $assetUrl: String, $commentId: ID!, $hasComment: Boolean!, $excludeIgnored: Boolean) {
|
||||
asset(id: $assetId, url: $assetUrl) {
|
||||
totalCommentCount(excludeIgnored: $excludeIgnored)
|
||||
}
|
||||
me {
|
||||
id
|
||||
status
|
||||
@@ -187,9 +183,11 @@ const mapDispatchToProps = (dispatch) =>
|
||||
logout,
|
||||
checkLogin,
|
||||
setActiveTab,
|
||||
viewAllComments,
|
||||
fetchAssetSuccess,
|
||||
addNotification,
|
||||
focusSignInDialog,
|
||||
blurSignInDialog,
|
||||
hideSignInDialog,
|
||||
},
|
||||
dispatch
|
||||
);
|
||||
|
||||
@@ -4,19 +4,19 @@ import {connect} from 'react-redux';
|
||||
import {bindActionCreators} from 'redux';
|
||||
import {ADDTL_COMMENTS_ON_LOAD_MORE, THREADING_LEVEL} from '../constants/stream';
|
||||
import {
|
||||
withPostComment, withPostFlag, withPostDontAgree, withDeleteAction,
|
||||
withAddTag, withRemoveTag, withIgnoreUser, withEditComment,
|
||||
withPostComment, withPostFlag, withPostDontAgree,
|
||||
withDeleteAction, withIgnoreUser, withEditComment
|
||||
} from 'coral-framework/graphql/mutations';
|
||||
|
||||
import * as authActions from 'coral-framework/actions/auth';
|
||||
import * as notificationActions from 'coral-framework/actions/notification';
|
||||
import {editName} from 'coral-framework/actions/user';
|
||||
import {setActiveReplyBox} from '../actions/stream';
|
||||
import {setActiveReplyBox, setActiveTab, viewAllComments} from '../actions/stream';
|
||||
import Stream from '../components/Stream';
|
||||
import Comment from './Comment';
|
||||
import {withFragments} from 'coral-framework/hocs';
|
||||
import {getDefinitionName, getSlotFragmentSpreads} from 'coral-framework/utils';
|
||||
import {Spinner} from 'coral-ui';
|
||||
import {getDefinitionName} from 'coral-framework/utils';
|
||||
import {
|
||||
findCommentInEmbedQuery,
|
||||
insertCommentIntoEmbedQuery,
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
insertFetchedCommentsIntoEmbedQuery,
|
||||
nest,
|
||||
} from '../graphql/utils';
|
||||
import omit from 'lodash/omit';
|
||||
|
||||
const {showSignInDialog} = authActions;
|
||||
const {addNotification} = notificationActions;
|
||||
@@ -158,7 +159,6 @@ const commentFragment = gql`
|
||||
id
|
||||
...${getDefinitionName(Comment.fragments.comment)}
|
||||
${nest(`
|
||||
replyCount(excludeIgnored: $excludeIgnored)
|
||||
replies(excludeIgnored: $excludeIgnored) {
|
||||
nodes {
|
||||
id
|
||||
@@ -209,7 +209,6 @@ const LOAD_MORE_QUERY = gql`
|
||||
id
|
||||
...${getDefinitionName(Comment.fragments.comment)}
|
||||
${nest(`
|
||||
replyCount(excludeIgnored: $excludeIgnored)
|
||||
replies(limit: 3, excludeIgnored: $excludeIgnored) {
|
||||
nodes {
|
||||
id
|
||||
@@ -230,6 +229,11 @@ const LOAD_MORE_QUERY = gql`
|
||||
${Comment.fragments.comment}
|
||||
`;
|
||||
|
||||
const slots = [
|
||||
'streamTabs',
|
||||
'streamTabPanes',
|
||||
];
|
||||
|
||||
const fragments = {
|
||||
root: gql`
|
||||
fragment CoralEmbedStream_Stream_root on RootQuery {
|
||||
@@ -271,6 +275,8 @@ const fragments = {
|
||||
startCursor
|
||||
endCursor
|
||||
}
|
||||
${getSlotFragmentSpreads(slots, 'asset')}
|
||||
...${getDefinitionName(Comment.fragments.asset)}
|
||||
}
|
||||
me {
|
||||
status
|
||||
@@ -281,8 +287,10 @@ const fragments = {
|
||||
settings {
|
||||
organizationName
|
||||
}
|
||||
${getSlotFragmentSpreads(slots, 'root')}
|
||||
...${getDefinitionName(Comment.fragments.root)}
|
||||
}
|
||||
${Comment.fragments.asset}
|
||||
${Comment.fragments.root}
|
||||
${commentFragment}
|
||||
`,
|
||||
@@ -298,7 +306,11 @@ const mapStateToProps = (state) => ({
|
||||
assetUrl: state.stream.assetUrl,
|
||||
activeTab: state.embed.activeTab,
|
||||
previousTab: state.embed.previousTab,
|
||||
commentClassNames: state.stream.commentClassNames
|
||||
activeStreamTab: state.stream.activeTab,
|
||||
previousStreamTab: state.stream.previousTab,
|
||||
commentClassNames: state.stream.commentClassNames,
|
||||
pluginConfig: state.config.plugin_config,
|
||||
reduxState: omit(state, 'apollo'),
|
||||
});
|
||||
|
||||
const mapDispatchToProps = (dispatch) =>
|
||||
@@ -307,6 +319,8 @@ const mapDispatchToProps = (dispatch) =>
|
||||
addNotification,
|
||||
setActiveReplyBox,
|
||||
editName,
|
||||
viewAllComments,
|
||||
setActiveStreamTab: setActiveTab,
|
||||
}, dispatch);
|
||||
|
||||
export default compose(
|
||||
@@ -315,8 +329,6 @@ export default compose(
|
||||
withPostComment,
|
||||
withPostFlag,
|
||||
withPostDontAgree,
|
||||
withAddTag,
|
||||
withRemoveTag,
|
||||
withIgnoreUser,
|
||||
withDeleteAction,
|
||||
withEditComment,
|
||||
|
||||
@@ -84,7 +84,7 @@ const extension = {
|
||||
}
|
||||
user {
|
||||
id
|
||||
name: username
|
||||
username
|
||||
}
|
||||
action_summaries {
|
||||
count
|
||||
@@ -143,7 +143,7 @@ const extension = {
|
||||
user: {
|
||||
__typename: 'User',
|
||||
id: auth.toJS().user.id,
|
||||
name: auth.toJS().user.username
|
||||
username: auth.toJS().user.username
|
||||
},
|
||||
created_at: new Date().toISOString(),
|
||||
body,
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
import update from 'immutability-helper';
|
||||
import {THREADING_LEVEL} from '../constants/stream';
|
||||
import {insertCommentsSorted} from 'coral-framework/utils';
|
||||
|
||||
function determineCommentDepth(comment) {
|
||||
let depth = 0;
|
||||
let cur = comment;
|
||||
while (cur.parent) {
|
||||
cur = cur.parent;
|
||||
depth++;
|
||||
}
|
||||
return depth;
|
||||
}
|
||||
|
||||
function applyToCommentsOrigin(root, callback) {
|
||||
if (root.comment) {
|
||||
let comment = root.comment;
|
||||
for (let depth = 0; depth <= THREADING_LEVEL; depth++) {
|
||||
for (let depth = 0; depth <= determineCommentDepth(comment); depth++) {
|
||||
let changes = {$apply: (node) => node ? callback(node) : node};
|
||||
for (let i = 0; i < depth; i++) {
|
||||
changes = {parent: changes};
|
||||
@@ -134,14 +144,6 @@ export function findCommentInEmbedQuery(root, callbackOrId) {
|
||||
return findComment(root.asset.comments.nodes, callback);
|
||||
}
|
||||
|
||||
const ascending = (a, b) => {
|
||||
const dateA = new Date(a.created_at);
|
||||
const dateB = new Date(b.created_at);
|
||||
if (dateA < dateB) { return -1; }
|
||||
if (dateA > dateB) { return 1; }
|
||||
return 0;
|
||||
};
|
||||
|
||||
function findAndInsertFetchedComments(parent, comments, parent_id) {
|
||||
const isAsset = parent.__typename === 'Asset';
|
||||
const connectionField = isAsset ? 'comments' : 'replies';
|
||||
@@ -154,11 +156,9 @@ function findAndInsertFetchedComments(parent, comments, parent_id) {
|
||||
if (isAsset) {
|
||||
return nodes.concat(comments.nodes);
|
||||
}
|
||||
return nodes
|
||||
.concat(comments.nodes.filter(
|
||||
return insertCommentsSorted(nodes, comments.nodes.filter(
|
||||
(comment) => !nodes.some((node) => node.id === comment.id)
|
||||
))
|
||||
.sort(ascending);
|
||||
));
|
||||
}},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -48,5 +48,5 @@ render(
|
||||
<ApolloProvider client={client} store={store}>
|
||||
<AppRouter />
|
||||
</ApolloProvider>
|
||||
, document.querySelector('#coralStream')
|
||||
, document.querySelector('#talk-embed-stream-container')
|
||||
);
|
||||
|
||||
@@ -17,14 +17,22 @@ function getQueryVariable(variable) {
|
||||
|
||||
const initialState = {
|
||||
activeReplyBox: '',
|
||||
assetId: getQueryVariable('asset_id'),
|
||||
assetUrl: getQueryVariable('asset_url'),
|
||||
commentId: getQueryVariable('comment_id'),
|
||||
commentClassNames: []
|
||||
assetId: getQueryVariable('asset_id') || '',
|
||||
assetUrl: getQueryVariable('asset_url') || '',
|
||||
commentId: getQueryVariable('comment_id') || '',
|
||||
commentClassNames: [],
|
||||
activeTab: process.env.TALK_DEFAULT_STREAM_TAB,
|
||||
previousTab: '',
|
||||
};
|
||||
|
||||
export default function stream(state = initialState, action) {
|
||||
switch (action.type) {
|
||||
case actions.SET_ACTIVE_TAB:
|
||||
return {
|
||||
...state,
|
||||
activeTab: action.tab,
|
||||
previousTab: state.activeTab,
|
||||
};
|
||||
case authActions.LOGOUT:
|
||||
return {
|
||||
...state,
|
||||
@@ -40,6 +48,11 @@ export default function stream(state = initialState, action) {
|
||||
...state,
|
||||
commentId: '',
|
||||
};
|
||||
case actions.VIEW_COMMENT:
|
||||
return {
|
||||
...state,
|
||||
commentId: action.id,
|
||||
};
|
||||
case actions.ADD_COMMENT_CLASSNAME :
|
||||
return {
|
||||
...state,
|
||||
|
||||
@@ -17,7 +17,7 @@ body {
|
||||
height: auto !important;
|
||||
}
|
||||
|
||||
#coralStream {
|
||||
#talk-embed-stream-container {
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
@@ -25,24 +25,24 @@ body {
|
||||
min-height: 600px;
|
||||
}
|
||||
|
||||
button {
|
||||
margin: 5px 0px 5px 0px;
|
||||
.coralButton {
|
||||
margin: 5px 10px 5px 0px;
|
||||
background: none;
|
||||
padding: 0px;
|
||||
border: none;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
.coralButton:hover {
|
||||
border-radius: 2px;
|
||||
color: #767676;
|
||||
}
|
||||
|
||||
button i {
|
||||
.coralButton i {
|
||||
margin-right: 3px;
|
||||
}
|
||||
|
||||
hr {
|
||||
.coralHr {
|
||||
border: 0;
|
||||
height: 0;
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.1);
|
||||
@@ -112,13 +112,6 @@ hr {
|
||||
color: #2a2a2a;
|
||||
}
|
||||
|
||||
.commentStream .material-icons {
|
||||
vertical-align: middle;
|
||||
width: 1em;
|
||||
font-size: 1em;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Question Box Styles */
|
||||
.coral-plugin-questionbox-info {
|
||||
top: 0;
|
||||
@@ -181,21 +174,10 @@ hr {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.talk-stream-wrapper-box {
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.talk-stream-comments-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.commentStream {
|
||||
/* prevent absolutely positioned final permalink popover from being clipped */
|
||||
padding-bottom: 50px;
|
||||
min-height: 600px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Comment Box Styles */
|
||||
.talk-plugin-commentbox-container {
|
||||
display: flex;
|
||||
@@ -289,11 +271,6 @@ hr {
|
||||
float: left;
|
||||
}
|
||||
|
||||
.comment__action-container .material-icons {
|
||||
font-size: 12px;
|
||||
margin-left: 3px;
|
||||
}
|
||||
|
||||
button.comment__action-button,
|
||||
.comment__action-button button {
|
||||
cursor: pointer;
|
||||
@@ -426,9 +403,8 @@ button.comment__action-button[disabled],
|
||||
|
||||
.talk-new-comments {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.talk-load-more-replies {
|
||||
@@ -437,12 +413,6 @@ button.comment__action-button[disabled],
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.talk-new-comments {
|
||||
position: relative;
|
||||
top: 1.8em;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.talk-load-more-replies .talk-load-more-button {
|
||||
background-color: transparent;
|
||||
color: #979797;
|
||||
@@ -455,18 +425,4 @@ button.comment__action-button[disabled],
|
||||
color: white;
|
||||
}
|
||||
|
||||
.talk-new-comments button.talk-load-more{
|
||||
width: initial;
|
||||
}
|
||||
|
||||
|
||||
@media (min-device-width : 300px) and (max-device-width : 420px) {
|
||||
.commentActionsLeft.comment__action-container .coral-plugin-replies-reply-button {
|
||||
visibility: collapse;
|
||||
margin-left: -30px;
|
||||
}
|
||||
|
||||
.commentActionsLeft.comment__action-container .coral-plugin-replies-reply-button .coral-plugin-replies-icon {
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import pym from 'pym.js';
|
||||
import URLSearchParams from 'url-search-params';
|
||||
|
||||
import {stringify} from 'querystring';
|
||||
import {buildUrl} from 'coral-framework/utils';
|
||||
import queryString from 'query-string';
|
||||
|
||||
// TODO: Styles should live in a separate file
|
||||
const snackbarStyles = {
|
||||
@@ -39,7 +40,7 @@ function buildStreamIframeUrl(talkBaseUrl, query) {
|
||||
'embed/stream?'
|
||||
].join('');
|
||||
|
||||
url += stringify(query);
|
||||
url += queryString.stringify(query);
|
||||
|
||||
return url;
|
||||
}
|
||||
@@ -98,10 +99,37 @@ function configurePymParent(pymParent, opts) {
|
||||
|
||||
// remove the permalink comment id from the hash
|
||||
pymParent.onMessage('coral-view-all-comments', function() {
|
||||
|
||||
const search = queryString.stringify({
|
||||
...queryString.parse(location.search),
|
||||
commentId: undefined,
|
||||
});
|
||||
|
||||
// remove the commentId url param
|
||||
const url = buildUrl({...location, search});
|
||||
|
||||
window.history.replaceState(
|
||||
{},
|
||||
document.title,
|
||||
location.origin + location.pathname + location.search
|
||||
url,
|
||||
);
|
||||
});
|
||||
|
||||
// remove the permalink comment id from the hash
|
||||
pymParent.onMessage('coral-view-comment', function(id) {
|
||||
|
||||
const search = queryString.stringify({
|
||||
...queryString.parse(location.search),
|
||||
commentId: id,
|
||||
});
|
||||
|
||||
// remove the commentId url param
|
||||
const url = buildUrl({...location, search});
|
||||
|
||||
window.history.replaceState(
|
||||
{},
|
||||
document.title,
|
||||
url,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -193,12 +221,18 @@ Talk.render = function(el, opts) {
|
||||
|
||||
let urlParams = new URLSearchParams(window.location.search);
|
||||
|
||||
query.comment_id = urlParams.get('commentId');
|
||||
if (urlParams.get('commentId')) {
|
||||
query.comment_id = urlParams.get('commentId');
|
||||
}
|
||||
|
||||
query.asset_id = opts.asset_id;
|
||||
if (opts.asset_id) {
|
||||
query.asset_id = opts.asset_id;
|
||||
}
|
||||
|
||||
query.asset_url = opts.asset_url;
|
||||
if (!query.asset_url) {
|
||||
if (opts.asset_url) {
|
||||
query.asset_url = opts.asset_url;
|
||||
}
|
||||
else {
|
||||
try {
|
||||
query.asset_url = document.querySelector('link[rel="canonical"]').href;
|
||||
} catch (e) {
|
||||
|
||||
@@ -7,45 +7,33 @@ import pym from '../services/pym';
|
||||
|
||||
import {resetWebsocket} from 'coral-framework/services/client';
|
||||
import t from 'coral-framework/services/i18n';
|
||||
import {isSlotEmpty} from 'plugin-api/beta/client/services';
|
||||
|
||||
export const showSignInDialog = () => (dispatch, getState) => {
|
||||
if (isSlotEmpty('login')) {
|
||||
return;
|
||||
}
|
||||
const signInPopUp = window.open(
|
||||
'/embed/stream/login',
|
||||
'Login',
|
||||
'menubar=0,resizable=0,width=500,height=550,top=200,left=500'
|
||||
);
|
||||
export const showSignInDialog = () => ({
|
||||
type: actions.SHOW_SIGNIN_DIALOG,
|
||||
});
|
||||
|
||||
// Workaround odd behavior in older WebKit versions, where
|
||||
// onunload is called twice. (Encountered in IOS 8.3)
|
||||
let loaded = false;
|
||||
signInPopUp.onload = () => {
|
||||
loaded = true;
|
||||
|
||||
// Fire some actions inside the popups reducer, to initialize required state.
|
||||
const required = getState().asset.toJS().settings.requireEmailConfirmation;
|
||||
const redirectUri = getState().auth.toJS().redirectUri;
|
||||
signInPopUp.coralStore.dispatch(setRequireEmailVerification(required));
|
||||
signInPopUp.coralStore.dispatch(setRedirectUri(redirectUri));
|
||||
};
|
||||
|
||||
// Use `onunload` instead of `onbeforeunload` which is not supported in IOS Safari.
|
||||
signInPopUp.onunload = () => {
|
||||
if (loaded) {
|
||||
dispatch(checkLogin());
|
||||
}
|
||||
};
|
||||
|
||||
dispatch({type: actions.SHOW_SIGNIN_DIALOG});
|
||||
};
|
||||
export const hideSignInDialog = () => (dispatch) => {
|
||||
if (window.opener && window.opener !== window) {
|
||||
|
||||
// TODO: We need to address this when we refactor the
|
||||
// login popup out of the embed.
|
||||
|
||||
// we are in a popup
|
||||
window.close();
|
||||
} else {
|
||||
dispatch(checkLogin());
|
||||
}
|
||||
dispatch({type: actions.HIDE_SIGNIN_DIALOG});
|
||||
window.close();
|
||||
};
|
||||
|
||||
export const focusSignInDialog = () => ({
|
||||
type: actions.FOCUS_SIGNIN_DIALOG,
|
||||
});
|
||||
|
||||
export const blurSignInDialog = () => ({
|
||||
type: actions.BLUR_SIGNIN_DIALOG,
|
||||
});
|
||||
|
||||
export const createUsernameRequest = () => ({
|
||||
type: actions.CREATE_USERNAME_REQUEST
|
||||
});
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import React from 'react';
|
||||
import {connect} from 'react-redux';
|
||||
import {isSlotEmpty} from 'coral-framework/helpers/plugins';
|
||||
import PropTypes from 'prop-types';
|
||||
import omit from 'lodash/omit';
|
||||
|
||||
function IfSlotIsEmpty({slot, className, reduxState, component: Component = 'div', children, ...rest}) {
|
||||
return (
|
||||
<Component className={className}>
|
||||
{isSlotEmpty(slot, reduxState, rest) ? children : null}
|
||||
</Component>
|
||||
);
|
||||
}
|
||||
|
||||
IfSlotIsEmpty.propTypes = {
|
||||
slot: PropTypes.string,
|
||||
className: PropTypes.string,
|
||||
};
|
||||
|
||||
const mapStateToProps = (state) => ({
|
||||
reduxState: omit(state, 'apollo'),
|
||||
});
|
||||
|
||||
export default connect(mapStateToProps, null)(IfSlotIsEmpty);
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import React from 'react';
|
||||
import {connect} from 'react-redux';
|
||||
import {isSlotEmpty} from 'coral-framework/helpers/plugins';
|
||||
import PropTypes from 'prop-types';
|
||||
import omit from 'lodash/omit';
|
||||
|
||||
function IfSlotIsNotEmpty({slot, className, reduxState, component: Component = 'div', children, ...rest}) {
|
||||
return (
|
||||
<Component className={className}>
|
||||
{!isSlotEmpty(slot, reduxState, rest) ? children : null}
|
||||
</Component>
|
||||
);
|
||||
}
|
||||
|
||||
IfSlotIsNotEmpty.propTypes = {
|
||||
slot: PropTypes.string,
|
||||
className: PropTypes.string,
|
||||
};
|
||||
|
||||
const mapStateToProps = (state) => ({
|
||||
reduxState: omit(state, 'apollo'),
|
||||
});
|
||||
|
||||
export default connect(mapStateToProps, null)(IfSlotIsNotEmpty);
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import {Component} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
export default class Popup extends Component {
|
||||
ref = null;
|
||||
detectCloseInterval = null;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
if (props.open) {
|
||||
this.openWindow(props);
|
||||
}
|
||||
}
|
||||
|
||||
openWindow(props = this.props) {
|
||||
this.ref = window.open(
|
||||
props.href,
|
||||
props.title,
|
||||
props.features,
|
||||
);
|
||||
|
||||
this.setCallbacks();
|
||||
}
|
||||
|
||||
setCallbacks() {
|
||||
this.ref.onload = () => {
|
||||
clearInterval(this.detectCloseInterval);
|
||||
this.onLoad();
|
||||
};
|
||||
|
||||
this.ref.onfocus = () => {
|
||||
this.onFocus();
|
||||
};
|
||||
|
||||
this.ref.onblur = () => {
|
||||
this.onBlur();
|
||||
};
|
||||
|
||||
// Use `onunload` instead of `onbeforeunload` which is not supported in IOS Safari.
|
||||
this.ref.onunload = () => {
|
||||
this.onUnload();
|
||||
|
||||
const interval = setInterval(() => {
|
||||
if (this.ref.onload === null) {
|
||||
this.setCallbacks();
|
||||
clearInterval(interval);
|
||||
}
|
||||
}, 50);
|
||||
|
||||
this.detectCloseInterval = setInterval(() => {
|
||||
if (this.ref.closed) {
|
||||
clearInterval(this.detectCloseInterval);
|
||||
this.onClose();
|
||||
}
|
||||
}, 50);
|
||||
};
|
||||
}
|
||||
|
||||
closeWindow() {
|
||||
if (this.ref) {
|
||||
if (!this.ref.closed) {
|
||||
this.ref.close();
|
||||
}
|
||||
this.ref = null;
|
||||
}
|
||||
}
|
||||
|
||||
focusWindow() {
|
||||
if (this.ref && !this.ref.closed) {
|
||||
this.ref.focus();
|
||||
}
|
||||
}
|
||||
|
||||
blurWindow() {
|
||||
if (this.ref && !this.ref.closed) {
|
||||
this.ref.blur();
|
||||
}
|
||||
}
|
||||
|
||||
onLoad = () => {
|
||||
if (this.props.onLoad) {
|
||||
this.props.onLoad();
|
||||
}
|
||||
}
|
||||
|
||||
onUnload = () => {
|
||||
if (this.props.onUnload) {
|
||||
this.props.onUnload();
|
||||
}
|
||||
}
|
||||
|
||||
onClose = () => {
|
||||
if (this.props.onClose) {
|
||||
this.props.onClose();
|
||||
}
|
||||
}
|
||||
|
||||
onFocus = () => {
|
||||
if (this.props.onFocus) {
|
||||
this.props.onFocus();
|
||||
}
|
||||
}
|
||||
|
||||
onBlur = () => {
|
||||
if (this.props.onBlur) {
|
||||
this.props.onBlur();
|
||||
}
|
||||
}
|
||||
|
||||
componentWillReceiveProps(nextProps) {
|
||||
if (nextProps.open && !this.ref) {
|
||||
this.openWindow(nextProps);
|
||||
}
|
||||
|
||||
if (this.props.open && !nextProps.open) {
|
||||
this.closeWindow();
|
||||
}
|
||||
|
||||
if (!this.props.focus && nextProps.focus) {
|
||||
this.focusWindow();
|
||||
}
|
||||
|
||||
if (this.props.focus && !nextProps.focus) {
|
||||
this.blurWindow();
|
||||
}
|
||||
|
||||
if (this.props.href !== nextProps.href) {
|
||||
this.ref.location.href = nextProps.href;
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.closeWindow();
|
||||
}
|
||||
|
||||
render() {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Popup.propTypes = {
|
||||
open: PropTypes.bool,
|
||||
focus: PropTypes.bool,
|
||||
onFocus: PropTypes.func,
|
||||
onBlur: PropTypes.func,
|
||||
onLoad: PropTypes.func,
|
||||
onUnload: PropTypes.func,
|
||||
onClose: PropTypes.func,
|
||||
href: PropTypes.string.isRequired,
|
||||
features: PropTypes.string,
|
||||
};
|
||||
@@ -3,15 +3,17 @@ import cn from 'classnames';
|
||||
import styles from './Slot.css';
|
||||
import {connect} from 'react-redux';
|
||||
import {getSlotElements} from 'coral-framework/helpers/plugins';
|
||||
import omit from 'lodash/omit';
|
||||
|
||||
function Slot ({fill, inline = false, className, plugin_config: config, defaultComponent: DefaultComponent, ...rest}) {
|
||||
let children = getSlotElements(fill, {...rest, config});
|
||||
function Slot ({fill, inline = false, className, reduxState, defaultComponent: DefaultComponent, ...rest}) {
|
||||
let children = getSlotElements(fill, reduxState, rest);
|
||||
const pluginConfig = reduxState.config.pluginConfig || {};
|
||||
if (children.length === 0 && DefaultComponent) {
|
||||
children = <DefaultComponent {...rest} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn({[styles.inline]: inline, [styles.debug]: config.debug}, className)}>
|
||||
<div className={cn({[styles.inline]: inline, [styles.debug]: pluginConfig.debug}, className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
@@ -21,7 +23,9 @@ Slot.propTypes = {
|
||||
fill: React.PropTypes.string
|
||||
};
|
||||
|
||||
const mapStateToProps = ({config: {plugin_config = {}}}) => ({plugin_config});
|
||||
const mapStateToProps = (state) => ({
|
||||
reduxState: omit(state, 'apollo'),
|
||||
});
|
||||
|
||||
export default connect(mapStateToProps, null)(Slot);
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@ export const CLEAN_STATE = 'CLEAN_STATE';
|
||||
|
||||
export const SHOW_SIGNIN_DIALOG = 'SHOW_SIGNIN_DIALOG';
|
||||
export const HIDE_SIGNIN_DIALOG = 'HIDE_SIGNIN_DIALOG';
|
||||
export const FOCUS_SIGNIN_DIALOG = 'FOCUS_SIGNIN_DIALOG';
|
||||
export const BLUR_SIGNIN_DIALOG = 'BLUR_SIGNIN_DIALOG';
|
||||
|
||||
export const CREATE_USERNAME_REQUEST = 'CREATE_USERNAME_REQUEST';
|
||||
export const CREATE_USERNAME_SUCCESS = 'CREATE_USERNAME_SUCCESS';
|
||||
|
||||
@@ -1,6 +1,117 @@
|
||||
import {gql} from 'react-apollo';
|
||||
import withMutation from '../hocs/withMutation';
|
||||
|
||||
function convertItemType(item_type) {
|
||||
switch (item_type) {
|
||||
case 'COMMENTS':
|
||||
return 'Comment';
|
||||
case 'USERS':
|
||||
return 'User';
|
||||
case 'ASSETS':
|
||||
return 'Asset';
|
||||
default:
|
||||
throw new Error(`Unknown item_type ${item_type}`);
|
||||
}
|
||||
}
|
||||
|
||||
function getTagFragment(item_type) {
|
||||
return gql`
|
||||
fragment Coral_UpdateFragment on ${convertItemType(item_type)} {
|
||||
tags {
|
||||
tag {
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
export const withAddTag = withMutation(
|
||||
gql`
|
||||
mutation AddTag($id: ID!, $asset_id: ID!, $name: String!, $item_type: TAGGABLE_ITEM_TYPE!) {
|
||||
addTag(tag: {name: $name, id: $id, item_type: $item_type, asset_id: $asset_id}) {
|
||||
...ModifyTagResponse
|
||||
}
|
||||
}
|
||||
`, {
|
||||
props: ({mutate}) => ({
|
||||
addTag: ({id, name, assetId, itemType}) => {
|
||||
return mutate({
|
||||
variables: {
|
||||
id,
|
||||
name,
|
||||
asset_id: assetId,
|
||||
item_type: itemType,
|
||||
},
|
||||
optimisticResponse: {
|
||||
addTag: {
|
||||
__typename: 'ModifyTagResponse',
|
||||
errors: null,
|
||||
}
|
||||
},
|
||||
update: (proxy) => {
|
||||
const fragmentId = `${convertItemType(itemType)}_${id}`;
|
||||
const fragment = getTagFragment(itemType);
|
||||
|
||||
// Read the data from our cache for this query.
|
||||
const data = proxy.readFragment({fragment, id: fragmentId});
|
||||
|
||||
data.tags.push({
|
||||
tag: {
|
||||
__typename: 'Tag',
|
||||
name
|
||||
},
|
||||
__typename: 'TagLink'
|
||||
});
|
||||
|
||||
// Write our data back to the cache.
|
||||
proxy.writeFragment({fragment, id: fragmentId, data});
|
||||
},
|
||||
});
|
||||
}}),
|
||||
});
|
||||
|
||||
export const withRemoveTag = withMutation(
|
||||
gql`
|
||||
mutation RemoveTag($id: ID!, $asset_id: ID!, $name: String!, $item_type: TAGGABLE_ITEM_TYPE!) {
|
||||
removeTag(tag: {name: $name, id: $id, item_type: $item_type, asset_id: $asset_id}) {
|
||||
...ModifyTagResponse
|
||||
}
|
||||
}
|
||||
`, {
|
||||
props: ({mutate}) => ({
|
||||
removeTag: ({id, name, assetId, itemType}) => {
|
||||
return mutate({
|
||||
variables: {
|
||||
id,
|
||||
name,
|
||||
asset_id: assetId,
|
||||
item_type: itemType,
|
||||
},
|
||||
optimisticResponse: {
|
||||
removeTag: {
|
||||
__typename: 'ModifyTagResponse',
|
||||
errors: null,
|
||||
}
|
||||
},
|
||||
update: (proxy) => {
|
||||
const fragmentId = `${convertItemType(itemType)}_${id}`;
|
||||
const fragment = getTagFragment(itemType);
|
||||
|
||||
// Read the data from our cache for this query.
|
||||
const data = proxy.readFragment({fragment, id: fragmentId});
|
||||
|
||||
const idx = data.tags.findIndex((i) => i.tag.name === name);
|
||||
|
||||
data.tags = [...data.tags.slice(0, idx), ...data.tags.slice(idx + 1)];
|
||||
|
||||
// Write our data back to the cache.
|
||||
proxy.writeFragment({fragment, id: fragmentId, data});
|
||||
}
|
||||
});
|
||||
}}),
|
||||
});
|
||||
|
||||
export const withSetCommentStatus = withMutation(
|
||||
gql`
|
||||
mutation SetCommentStatus($commentId: ID!, $status: COMMENT_STATUS!){
|
||||
@@ -173,98 +284,6 @@ export const withDeleteAction = withMutation(
|
||||
}}),
|
||||
});
|
||||
|
||||
const COMMENT_FRAGMENT = gql`
|
||||
fragment CoralBest_UpdateFragment on Comment {
|
||||
tags {
|
||||
tag {
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const withAddTag = withMutation(
|
||||
gql`
|
||||
mutation AddTag($id: ID!, $asset_id: ID!, $name: String!) {
|
||||
addTag(tag: {name: $name, id: $id, item_type: COMMENTS, asset_id: $asset_id}) {
|
||||
...ModifyTagResponse
|
||||
}
|
||||
}
|
||||
`, {
|
||||
props: ({mutate}) => ({
|
||||
addTag: ({id, name, assetId}) => {
|
||||
return mutate({
|
||||
variables: {
|
||||
id,
|
||||
name,
|
||||
asset_id: assetId
|
||||
},
|
||||
optimisticResponse: {
|
||||
addTag: {
|
||||
__typename: 'ModifyTagResponse',
|
||||
errors: null,
|
||||
}
|
||||
},
|
||||
update: (proxy) => {
|
||||
const fragmentId = `Comment_${id}`;
|
||||
|
||||
// Read the data from our cache for this query.
|
||||
const data = proxy.readFragment({fragment: COMMENT_FRAGMENT, id: fragmentId});
|
||||
|
||||
data.tags.push({
|
||||
tag: {
|
||||
__typename: 'Tag',
|
||||
name: 'BEST'
|
||||
},
|
||||
__typename: 'TagLink'
|
||||
});
|
||||
|
||||
// Write our data back to the cache.
|
||||
proxy.writeFragment({fragment: COMMENT_FRAGMENT, id: fragmentId, data});
|
||||
},
|
||||
});
|
||||
}}),
|
||||
});
|
||||
|
||||
export const withRemoveTag = withMutation(
|
||||
gql`
|
||||
mutation RemoveTag($id: ID!, $asset_id: ID!, $name: String!) {
|
||||
removeTag(tag: {name: $name, id: $id, item_type: COMMENTS, asset_id: $asset_id}) {
|
||||
...ModifyTagResponse
|
||||
}
|
||||
}
|
||||
`, {
|
||||
props: ({mutate}) => ({
|
||||
removeTag: ({id, name, assetId}) => {
|
||||
return mutate({
|
||||
variables: {
|
||||
id,
|
||||
name,
|
||||
asset_id: assetId
|
||||
},
|
||||
optimisticResponse: {
|
||||
removeTag: {
|
||||
__typename: 'ModifyTagResponse',
|
||||
errors: null,
|
||||
}
|
||||
},
|
||||
update: (proxy) => {
|
||||
const fragmentId = `Comment_${id}`;
|
||||
|
||||
// Read the data from our cache for this query.
|
||||
const data = proxy.readFragment({fragment: COMMENT_FRAGMENT, id: fragmentId});
|
||||
|
||||
const idx = data.tags.findIndex((i) => i.tag.name === 'BEST');
|
||||
|
||||
data.tags = [...data.tags.slice(0, idx), ...data.tags.slice(idx + 1)];
|
||||
|
||||
// Write our data back to the cache.
|
||||
proxy.writeFragment({fragment: COMMENT_FRAGMENT, id: fragmentId, data});
|
||||
}
|
||||
});
|
||||
}}),
|
||||
});
|
||||
|
||||
export const withIgnoreUser = withMutation(
|
||||
gql`
|
||||
mutation IgnoreUser($id: ID!) {
|
||||
|
||||
@@ -2,95 +2,63 @@ import React from 'react';
|
||||
import uniq from 'lodash/uniq';
|
||||
import pick from 'lodash/pick';
|
||||
import merge from 'lodash/merge';
|
||||
import plugins from 'pluginsConfig';
|
||||
import flatten from 'lodash/flatten';
|
||||
import flattenDeep from 'lodash/flattenDeep';
|
||||
import {getDefinitionName, mergeDocuments} from 'coral-framework/utils';
|
||||
import flatten from 'lodash/flatten';
|
||||
import {loadTranslations} from 'coral-framework/services/i18n';
|
||||
import {injectReducers, getStore} from 'coral-framework/services/store';
|
||||
import {injectReducers} from 'coral-framework/services/store';
|
||||
import camelize from './camelize';
|
||||
import plugins from 'pluginsConfig';
|
||||
|
||||
function getSlotComponents(slot) {
|
||||
const pluginConfig = getStore().getState().config.plugin_config;
|
||||
|
||||
// Filter out components that have been disabled in `plugin_config`
|
||||
export function getSlotComponents(slot, reduxState, props = {}) {
|
||||
const pluginConfig = reduxState.config.pluginConfig || {};
|
||||
return flatten(plugins
|
||||
|
||||
// Filter out components that have been disabled in `plugin_config`
|
||||
.filter((o) => !pluginConfig || !pluginConfig[o.plugin] || !pluginConfig[o.plugin].disable_components)
|
||||
// Filter out components that have slots and have been disabled in `plugin_config`
|
||||
.filter((o) => o.module.slots && (!pluginConfig || !pluginConfig[o.name] || !pluginConfig[o.name].disable_components))
|
||||
|
||||
.filter((o) => o.module.slots[slot])
|
||||
.map((o) => o.module.slots[slot]));
|
||||
.filter((o) => o.module.slots[slot])
|
||||
.map((o) => o.module.slots[slot])
|
||||
)
|
||||
.filter((component) => {
|
||||
if(!component.isExcluded) {
|
||||
return true;
|
||||
}
|
||||
let resolvedProps = {...props, config: pluginConfig};
|
||||
if (component.mapStateToProps) {
|
||||
resolvedProps = {...resolvedProps, ...component.mapStateToProps(reduxState)};
|
||||
}
|
||||
return !component.isExcluded(resolvedProps);
|
||||
});
|
||||
}
|
||||
|
||||
export function isSlotEmpty(slot) {
|
||||
return getSlotComponents(slot).length === 0;
|
||||
export function isSlotEmpty(slot, reduxState, props) {
|
||||
return getSlotComponents(slot, reduxState, props).length === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns React Elements for given slot.
|
||||
*/
|
||||
export function getSlotElements(slot, props = {}) {
|
||||
return getSlotComponents(slot)
|
||||
.map((component, i) => React.createElement(component, {key: i, ...props}));
|
||||
export function getSlotElements(slot, reduxState, props = {}) {
|
||||
const pluginConfig = reduxState.config.pluginConfig || {};
|
||||
return getSlotComponents(slot, reduxState, props)
|
||||
.map((component, i) => React.createElement(component, {key: i, ...props, config: pluginConfig}));
|
||||
}
|
||||
|
||||
function getComponentFragments(components) {
|
||||
const res = components
|
||||
export function getSlotFragments(slot, part) {
|
||||
const components = uniq(flattenDeep(plugins
|
||||
.filter((o) => o.module.slots ? o.module.slots[slot] : false)
|
||||
.map((o) => o.module.slots[slot])
|
||||
));
|
||||
|
||||
const documents = components
|
||||
.map((c) => c.fragments)
|
||||
.filter((fragments) => fragments)
|
||||
.filter((fragments) => fragments && fragments[part])
|
||||
.reduce((res, fragments) => {
|
||||
Object.keys(fragments).forEach((key) => {
|
||||
if (!(key in res)) {
|
||||
res[key] = {spreads: [], definitions: []};
|
||||
}
|
||||
res[key].spreads.push(getDefinitionName(fragments[key]));
|
||||
res[key].definitions.push(fragments[key]);
|
||||
});
|
||||
res.push(fragments[part]);
|
||||
return res;
|
||||
}, {});
|
||||
}, []);
|
||||
|
||||
Object.keys(res).forEach((key) => {
|
||||
|
||||
// Assemble arguments for `gql` to call it directly without using template literals.
|
||||
res[key].spreads = `...${res[key].spreads.join('\n...')}\n`;
|
||||
res[key].definitions = mergeDocuments(res[key].definitions);
|
||||
});
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an object that can be used to compose fragments or queries.
|
||||
*
|
||||
* Example:
|
||||
* const pluginFragments = getSlotsFragments(['commentInfoBar', 'commentActions']);
|
||||
* const rootFragment = gql`
|
||||
* fragment Comment_root on RootQuery {
|
||||
+ ${pluginFragments.spreads('root')}
|
||||
* }
|
||||
* ${pluginFragments.definitions('root')}
|
||||
* `;
|
||||
*/
|
||||
export function getSlotsFragments(slots) {
|
||||
if (!Array.isArray(slots)) {
|
||||
slots = [slots];
|
||||
}
|
||||
const components = uniq(flattenDeep(slots.map((slot) => {
|
||||
return plugins
|
||||
.filter((o) => o.module.slots[slot])
|
||||
.map((o) => o.module.slots[slot]);
|
||||
})));
|
||||
|
||||
const fragments = getComponentFragments(components);
|
||||
return {
|
||||
spreads(key) {
|
||||
return (fragments[key] && fragments[key].spreads) || '';
|
||||
},
|
||||
definitions(key) {
|
||||
return (fragments[key] && fragments[key].definitions) || '';
|
||||
},
|
||||
};
|
||||
return documents;
|
||||
}
|
||||
|
||||
export function getGraphQLExtensions() {
|
||||
@@ -113,7 +81,22 @@ export function injectPluginsReducers() {
|
||||
const reducers = merge(
|
||||
...plugins
|
||||
.filter((o) => o.module.reducer)
|
||||
.map((o) => ({[camelize(o.plugin)] : o.module.reducer}))
|
||||
.map((o) => ({[camelize(o.name)] : o.module.reducer}))
|
||||
);
|
||||
injectReducers(reducers);
|
||||
}
|
||||
|
||||
function addMetaDataToSlotComponents() {
|
||||
|
||||
// Add talkPluginName to Slot Components.
|
||||
plugins.forEach((plugin) => {
|
||||
const slots = plugin.module.slots;
|
||||
slots && Object.keys(slots).forEach((slot) => {
|
||||
slots[slot].forEach((component) => {
|
||||
component.talkPluginName = plugin.name;
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
addMetaDataToSlotComponents();
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import {connect} from 'react-redux';
|
||||
|
||||
export default (mapStateToProps, ...rest) => (BaseComponent) => {
|
||||
BaseComponent.mapStateToProps = mapStateToProps;
|
||||
return connect(mapStateToProps, ...rest)(BaseComponent);
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export default (condition) => (BaseComponent) => {
|
||||
BaseComponent.isExcluded = condition;
|
||||
return BaseComponent;
|
||||
};
|
||||
@@ -1,14 +1,5 @@
|
||||
import React from 'react';
|
||||
import {getDisplayName} from '../helpers/hoc';
|
||||
|
||||
// TODO: revisit `filtering` after https://github.com/apollographql/graphql-anywhere/issues/38.
|
||||
export default (fragments) => (WrappedComponent) => {
|
||||
class WithFragments extends React.Component {
|
||||
render() {
|
||||
return <WrappedComponent {...this.props} />;
|
||||
}
|
||||
}
|
||||
WithFragments.fragments = fragments;
|
||||
WithFragments.displayName = `WithFragments(${getDisplayName(WrappedComponent)})`;
|
||||
return WithFragments;
|
||||
export default (fragments) => (BaseComponent) => {
|
||||
BaseComponent.fragments = fragments;
|
||||
return BaseComponent;
|
||||
};
|
||||
|
||||
@@ -15,14 +15,42 @@ const withSkipOnErrors = (reducer) => (prev, action, ...rest) => {
|
||||
* apply query options registered in the graphRegistry.
|
||||
*/
|
||||
export default (document, config = {}) => (WrappedComponent) => {
|
||||
config = {
|
||||
const wrappedConfig = {
|
||||
...config,
|
||||
options: config.options || {},
|
||||
props: config.props || (({data}) => separateDataAndRoot(data)),
|
||||
props: (args) => {
|
||||
const wrappedArgs = {
|
||||
...args,
|
||||
data: {
|
||||
...args.data,
|
||||
subscribeToMore(stmArgs) {
|
||||
|
||||
// Resolve document fragments before passing it to `apollo-client`.
|
||||
return args.data.subscribeToMore({
|
||||
...stmArgs,
|
||||
document: resolveFragments(stmArgs.document),
|
||||
});
|
||||
},
|
||||
fetchMore(lmArgs) {
|
||||
|
||||
// Resolve document fragments before passing it to `apollo-client`.
|
||||
return args.data.fetchMore({
|
||||
...lmArgs,
|
||||
query: resolveFragments(lmArgs.query),
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
return config.props
|
||||
? config.props(wrappedArgs)
|
||||
: separateDataAndRoot(wrappedArgs.data);
|
||||
},
|
||||
};
|
||||
|
||||
const wrappedOptions = (data) => {
|
||||
const base = (typeof config.options === 'function') ? config.options(data) : config.options;
|
||||
const base = (typeof wrappedConfig.options === 'function')
|
||||
? wrappedConfig.options(data)
|
||||
: wrappedConfig.options;
|
||||
const name = getDefinitionName(document);
|
||||
const configs = getQueryOptions(name);
|
||||
const reducerCallbacks =
|
||||
@@ -45,7 +73,7 @@ export default (document, config = {}) => (WrappedComponent) => {
|
||||
let memoized = null;
|
||||
const getWrapped = () => {
|
||||
if (!memoized) {
|
||||
memoized = graphql(resolveFragments(document), {...config, options: wrappedOptions})(WrappedComponent);
|
||||
memoized = graphql(resolveFragments(document), {...wrappedConfig, options: wrappedOptions})(WrappedComponent);
|
||||
}
|
||||
return memoized;
|
||||
};
|
||||
|
||||
@@ -22,7 +22,7 @@ module.exports = function(source) {
|
||||
const config = this.exec(source, this.resourcePath);
|
||||
const plugins = getPluginList(config).map((plugin) => `{
|
||||
module: require('${plugin}/client'),
|
||||
plugin: '${plugin}'
|
||||
name: '${plugin}'
|
||||
}`);
|
||||
|
||||
return stripIndent`
|
||||
|
||||
@@ -7,6 +7,7 @@ const initialState = Map({
|
||||
loggedIn: false,
|
||||
user: null,
|
||||
showSignInDialog: false,
|
||||
signInDialogFocus: false,
|
||||
showCreateUsernameDialog: false,
|
||||
checkedInitialLogin: false,
|
||||
view: 'SIGNIN',
|
||||
@@ -29,13 +30,21 @@ const purge = (user) => {
|
||||
|
||||
export default function auth (state = initialState, action) {
|
||||
switch (action.type) {
|
||||
case actions.FOCUS_SIGNIN_DIALOG:
|
||||
return state
|
||||
.set('signInDialogFocus', true);
|
||||
case actions.BLUR_SIGNIN_DIALOG:
|
||||
return state
|
||||
.set('signInDialogFocus', false);
|
||||
case actions.SHOW_SIGNIN_DIALOG :
|
||||
return state
|
||||
.set('showSignInDialog', true);
|
||||
.set('showSignInDialog', true)
|
||||
.set('signInDialogFocus', true);
|
||||
case actions.HIDE_SIGNIN_DIALOG :
|
||||
return state.merge(Map({
|
||||
isLoading: false,
|
||||
showSignInDialog: false,
|
||||
signInDialogFocus: false,
|
||||
view: 'SIGNIN',
|
||||
error: '',
|
||||
passwordRequestFailure: null,
|
||||
|
||||
@@ -1,7 +1,18 @@
|
||||
import {getDefinitionName, mergeDocuments} from 'coral-framework/utils';
|
||||
import {getGraphQLExtensions} from 'coral-framework/helpers/plugins';
|
||||
import {getGraphQLExtensions, getSlotFragments} from 'coral-framework/helpers/plugins';
|
||||
import globalFragments from 'coral-framework/graphql/fragments';
|
||||
import uniq from 'lodash/uniq';
|
||||
import {gql} from 'react-apollo';
|
||||
|
||||
/*
|
||||
* Disable false-positive warning below, as it doesn't work well with how we currently
|
||||
* assemble the queries.
|
||||
*
|
||||
* Warning: fragment with name {fragment name} already exists.
|
||||
* graphql-tag enforces all fragment names across your application to be unique; read more about
|
||||
* this in the docs: http://dev.apollodata.com/core/fragments.html#unique-names
|
||||
*/
|
||||
gql.disableFragmentWarnings();
|
||||
|
||||
const fragments = {};
|
||||
const mutationOptions = {};
|
||||
@@ -139,18 +150,47 @@ export function getQueryOptions(key) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a document with a fragment named `key`, which contains
|
||||
* all fragments added under this key.
|
||||
* getSlotFragmentDocument handles `key`s in the form of TalkSlot_SlotName_Resource.
|
||||
* It parses the slot name and the resource and usees the plugin API to assemble
|
||||
* the fragment document.
|
||||
*/
|
||||
export function getFragmentDocument(key) {
|
||||
init();
|
||||
function getSlotFragmentDocument(key) {
|
||||
const match = key.match(/TalkSlot_(.*)_(.*)/);
|
||||
if (!match) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const slot = match[1][0].toLowerCase() + match[1].substr(1);
|
||||
const resource = match[2];
|
||||
const documents = getSlotFragments(slot, resource);
|
||||
|
||||
if (documents.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const names = documents.map((d) => getDefinitionName(d));
|
||||
const typeName = getTypeName(documents[0]);
|
||||
|
||||
// Assemble arguments for `gql` to call it directly without using template literals.
|
||||
const main = `
|
||||
fragment ${key} on ${typeName} {
|
||||
...${names.join('\n...')}\n
|
||||
}
|
||||
`;
|
||||
return mergeDocuments([main, ...documents]);
|
||||
}
|
||||
|
||||
/**
|
||||
* getRegistryFragmentDocument assembles a fragment document using
|
||||
* all registered fragment under given `key`.
|
||||
*/
|
||||
function getRegistryFragmentDocument(key) {
|
||||
if (!(key in fragments)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
let documents = fragments[key] ? fragments[key].documents : [];
|
||||
let fields = fragments[key] ? `...${fragments[key].names.join('\n...')}\n` : ' __typename';
|
||||
let documents = fragments[key].documents;
|
||||
let fields = `...${fragments[key].names.join('\n...')}\n`;
|
||||
|
||||
// Assemble arguments for `gql` to call it directly without using template literals.
|
||||
const main = `
|
||||
@@ -161,6 +201,16 @@ export function getFragmentDocument(key) {
|
||||
return mergeDocuments([main, ...documents]);
|
||||
}
|
||||
|
||||
/**
|
||||
* getFragmentDocument returns a fragment that assembles all registered
|
||||
* fragments under given `key` or if `key` refers to Slot fragments it will
|
||||
* return the slot fragments specified by this key.
|
||||
*/
|
||||
export function getFragmentDocument(key) {
|
||||
init();
|
||||
return getRegistryFragmentDocument(key) || getSlotFragmentDocument(key);
|
||||
}
|
||||
|
||||
// The fragments and configs are lazily loaded to allow circular dependencies to work.
|
||||
// TODO: We might want to change this to an explicit add after we have lazy Queries and Mutations.
|
||||
let initialized = false;
|
||||
@@ -178,19 +228,56 @@ function init() {
|
||||
getGraphQLExtensions().forEach((ext) => add(ext));
|
||||
}
|
||||
|
||||
/**
|
||||
* resolveFragments finds fragment spread names and attachs
|
||||
* the related fragment document to the given root document.
|
||||
*/
|
||||
export function resolveFragments(document) {
|
||||
if (document.loc.source) {
|
||||
|
||||
// resolve fragments from registry
|
||||
const matchedSubFragments = document.loc.source.body.match(/\.\.\.(.*)/g) || [];
|
||||
const subFragments =
|
||||
uniq(matchedSubFragments.map((f) => f.replace('...', '')))
|
||||
.map((key) => getFragmentDocument(key))
|
||||
.filter((i) => i);
|
||||
// Remember keys that we have already resolved.
|
||||
const resolvedKeys = [];
|
||||
|
||||
if (subFragments.length > 0) {
|
||||
return mergeDocuments([document, ...subFragments]);
|
||||
// Spreads from slots that are empty and need to be removed.
|
||||
// (works around the issue that we don't know the resource type
|
||||
// if we don't have a fragment)
|
||||
const spreadsToBeRemoved = [];
|
||||
|
||||
// fragments to be attached.
|
||||
const subFragments = [];
|
||||
|
||||
// body contains the final result.
|
||||
let body = document.loc.source.body;
|
||||
|
||||
let done = false;
|
||||
while (!done) {
|
||||
done = true;
|
||||
|
||||
const matchedSubFragments = body.match(/\.\.\.([_a-zA-Z][_a-zA-Z0-9]*)/g) || [];
|
||||
uniq(matchedSubFragments.map((f) => f.replace('...', '')))
|
||||
.filter((key) => resolvedKeys.indexOf(key) === -1)
|
||||
.forEach((key) => {
|
||||
const doc = getFragmentDocument(key);
|
||||
if (doc) {
|
||||
subFragments.push(doc);
|
||||
|
||||
// We found a new fragment, so we are not done yet.
|
||||
done = false;
|
||||
} else if(key.startsWith('TalkSlot_')) {
|
||||
spreadsToBeRemoved.push(key);
|
||||
}
|
||||
resolvedKeys.push(key);
|
||||
});
|
||||
|
||||
body = mergeDocuments([body, ...subFragments]).loc.source.body;
|
||||
}
|
||||
|
||||
spreadsToBeRemoved.forEach((key) => {
|
||||
const regex = new RegExp(`\\.\\.\\.${key}\n`, 'g');
|
||||
body = body.replace(regex, '');
|
||||
});
|
||||
|
||||
return gql`${body}`;
|
||||
} else {
|
||||
console.warn('Can only resolve fragments from documents definied using the gql tag.');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
.buttonReset {
|
||||
|
||||
/* reset button */
|
||||
user-select: none;
|
||||
outline: invert none medium;
|
||||
border: none;
|
||||
touch-action: manipulation;
|
||||
padding: 0;
|
||||
|
||||
overflow: hidden;
|
||||
|
||||
/* Unify anchor and button. */
|
||||
cursor: pointer;
|
||||
display: inline-block;
|
||||
box-sizing: border-box;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
align-items: flex-start;
|
||||
vertical-align: middle;
|
||||
white-space: nowrap;
|
||||
background: transparent;
|
||||
font-size: inherit;
|
||||
|
||||
-webkit-tap-highlight-color: rgba(0, 0, 0, 0) !important;
|
||||
&::-moz-focus-inner: {
|
||||
border: 0;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import {gql} from 'react-apollo';
|
||||
import t from 'coral-framework/services/i18n';
|
||||
import {capitalize} from 'coral-framework/helpers/strings';
|
||||
|
||||
export const getTotalActionCount = (type, comment) => {
|
||||
return comment.action_summaries
|
||||
@@ -144,3 +145,45 @@ export function forEachError(error, callback) {
|
||||
callback({error: e, msg});
|
||||
});
|
||||
}
|
||||
|
||||
const ascending = (a, b) => {
|
||||
const dateA = new Date(a.created_at);
|
||||
const dateB = new Date(b.created_at);
|
||||
if (dateA < dateB) { return -1; }
|
||||
if (dateA > dateB) { return 1; }
|
||||
return 0;
|
||||
};
|
||||
|
||||
const descending = (a, b) => ascending(a, b) * -1;
|
||||
|
||||
export function insertCommentsSorted(nodes, comments, sortOrder = 'CHRONOLOGICAL') {
|
||||
const added = nodes.concat(comments);
|
||||
if (sortOrder === 'CHRONOLOGICAL') {
|
||||
return added.sort(ascending);
|
||||
}
|
||||
if (sortOrder === 'REVERSE_CHRONOLOGICAL') {
|
||||
return added.sort(descending);
|
||||
}
|
||||
throw new Error(`Unknown sort order ${sortOrder}`);
|
||||
}
|
||||
|
||||
export const isTagged = (tags, which) => tags.some((t) => t.tag.name === which);
|
||||
|
||||
export function buildUrl({protocol, hostname, port, pathname, search, hash} = window.location) {
|
||||
if (search && search[0] !== '?') {
|
||||
search = `?${search}`;
|
||||
} else if (search === '?') {
|
||||
search = '';
|
||||
}
|
||||
return `${protocol}//${hostname}${port ? `:${port}` : ''}${pathname}${search}${hash}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* getSlotFragmentSpreads will return a string in the
|
||||
* expected format for slot fragments, given `slots` and `resource`.
|
||||
* e.g. `getSlotFragmentSpreads(['slotName'], 'root')` returns
|
||||
* `...TalkSlot_SlotName_root`.
|
||||
*/
|
||||
export function getSlotFragmentSpreads(slots, resource) {
|
||||
return `...${slots.map((s) => `TalkSlot_${capitalize(s)}_${resource}`).join('\n...')}\n`;
|
||||
}
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
import React, {Component, PropTypes} from 'react';
|
||||
|
||||
import t from 'coral-framework/services/i18n';
|
||||
|
||||
import {Icon} from 'coral-ui';
|
||||
import classnames from 'classnames';
|
||||
|
||||
// tag string for best comments
|
||||
export const BEST_TAG = 'BEST';
|
||||
|
||||
export const commentIsBest = ({tags} = {}) => tags.some((t) => t.tag.name === BEST_TAG);
|
||||
|
||||
const name = 'coral-plugin-best';
|
||||
|
||||
// It would be best if the backend/api held this business logic
|
||||
const canModifyBestTag = ({roles = []} = {}) => roles && ['ADMIN', 'MODERATOR'].some((role) => roles.includes(role));
|
||||
|
||||
// Put this on a comment to show that it is best
|
||||
|
||||
export const BestIndicator = ({children = <Icon name='star'/>}) => (
|
||||
<span aria-label={t('comment_is_best')}>
|
||||
{ children }
|
||||
</span>
|
||||
);
|
||||
|
||||
/**
|
||||
* Component that only renders children if the provided user prop can modify best tags
|
||||
*/
|
||||
export const IfUserCanModifyBest = ({user, children}) => {
|
||||
if (!(user && canModifyBestTag(user))) {return null;}
|
||||
return children;
|
||||
};
|
||||
|
||||
/**
|
||||
* Button that lets a moderator tag a comment as "Best".
|
||||
* Used to recognize really good comments.
|
||||
*/
|
||||
export class BestButton extends Component {
|
||||
static propTypes = {
|
||||
|
||||
// whether the comment is already tagged as best
|
||||
isBest: PropTypes.bool.isRequired,
|
||||
|
||||
// set that this comment is best
|
||||
addBest: PropTypes.func.isRequired,
|
||||
|
||||
// remove the best status
|
||||
removeBest: PropTypes.func.isRequired,
|
||||
}
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.onClickAddBest = this.onClickAddBest.bind(this);
|
||||
this.onClickRemoveBest = this.onClickRemoveBest.bind(this);
|
||||
}
|
||||
|
||||
state = {
|
||||
isSaving: false
|
||||
}
|
||||
|
||||
async onClickAddBest(e) {
|
||||
e.preventDefault();
|
||||
const {addBest} = this.props;
|
||||
if (!addBest) {
|
||||
console.warn('BestButton#onClickAddBest called even though there is no addBest prop. doing nothing');
|
||||
return;
|
||||
}
|
||||
this.setState({isSaving: true});
|
||||
try {
|
||||
await addBest();
|
||||
} finally {
|
||||
this.setState({isSaving: false});
|
||||
}
|
||||
}
|
||||
|
||||
async onClickRemoveBest(e) {
|
||||
e.preventDefault();
|
||||
const {removeBest} = this.props;
|
||||
if (!removeBest) {
|
||||
console.warn('BestButton#onClickAddBest called even though there is no removeBest prop. doing nothing');
|
||||
return;
|
||||
}
|
||||
this.setState({isSaving: true});
|
||||
try {
|
||||
await removeBest();
|
||||
} finally {
|
||||
this.setState({isSaving: false});
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const {isBest, addBest, removeBest} = this.props;
|
||||
const {isSaving} = this.state;
|
||||
const disabled = isSaving || !(isBest ? removeBest : addBest);
|
||||
return (
|
||||
<button onClick={isBest ? this.onClickRemoveBest : this.onClickAddBest}
|
||||
disabled={disabled}
|
||||
className={classnames(`${name}-button`, `e2e__${isBest ? 'unset' : 'set'}-best-comment`)}
|
||||
aria-label={t(isBest ? 'unset_best' : 'set_best')}>
|
||||
<Icon name={ isBest ? 'star' : 'star_border' } />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import React, {PropTypes} from 'react';
|
||||
|
||||
import t from 'coral-framework/services/i18n';
|
||||
|
||||
const name = 'coral-plugin-comment-count';
|
||||
|
||||
const CommentCount = ({count}) => {
|
||||
return <div className={`${name}-text`}>
|
||||
{`${count} ${count === 1 ? t('comment_singular') : t('comment_plural')}`}
|
||||
</div>;
|
||||
};
|
||||
|
||||
CommentCount.propTypes = {
|
||||
count: PropTypes.number.isRequired
|
||||
};
|
||||
|
||||
export default CommentCount;
|
||||
@@ -67,7 +67,7 @@ class CommentBox extends React.Component {
|
||||
|
||||
postComment(comment, 'comments')
|
||||
.then(({data}) => {
|
||||
this.setState({loadingState: 'success'});
|
||||
this.setState({loadingState: 'success', body: ''});
|
||||
const postedComment = data.createComment.comment;
|
||||
|
||||
// Execute postSubmit Hooks
|
||||
@@ -78,8 +78,6 @@ class CommentBox extends React.Component {
|
||||
if (commentPostedHandler) {
|
||||
commentPostedHandler();
|
||||
}
|
||||
|
||||
this.setState({body: ''});
|
||||
})
|
||||
.catch((err) => {
|
||||
this.setState({loadingState: 'error'});
|
||||
|
||||
@@ -148,14 +148,14 @@ export default class FlagButton extends Component {
|
||||
<button
|
||||
ref={(ref) => this.flagButton = ref}
|
||||
onClick={!this.props.banned && !flaggedByCurrentUser && !localPost ? this.onReportClick : null}
|
||||
className={cn(`${name}-button`, styles.button)}>
|
||||
className={cn(`${name}-button`, {[`${name}-button-flagged`]: flagged}, styles.button)}>
|
||||
{
|
||||
flagged
|
||||
? <span className={`${name}-button-text`}>{t('reported')}</span>
|
||||
: <span className={`${name}-button-text`}>{t('report')}</span>
|
||||
}
|
||||
<i className={
|
||||
cn(`${name}-icon`, 'material-icons', {
|
||||
cn(`${name}-icon`, 'material-icons', styles.icon, {
|
||||
flaggedIcon: flagged,
|
||||
[styles.flaggedIcon]: flagged,
|
||||
})}
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
.button {
|
||||
composes: buttonReset from "coral-framework/styles/reset.css";
|
||||
margin: 5px 0px 5px 10px;
|
||||
}
|
||||
|
||||
.flaggedIcon {
|
||||
color: #f00
|
||||
}
|
||||
|
||||
.icon {
|
||||
padding: 0 2px;
|
||||
font-size: 12px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
@@ -75,3 +75,8 @@
|
||||
margin: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.iconView, .iconDate {
|
||||
vertical-align: middle;
|
||||
padding-bottom: 1px;
|
||||
}
|
||||
|
||||
@@ -30,11 +30,11 @@ const Comment = (props) => {
|
||||
<ul>
|
||||
<li>
|
||||
<a onClick={props.link(`${props.asset.url}?commentId=${props.comment.id}`)}>
|
||||
<Icon name="open_in_new" />{t('view_conversation')}
|
||||
<Icon name="open_in_new" className={styles.iconView}/>{t('view_conversation')}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<Icon name="schedule" />
|
||||
<Icon name="schedule" className={styles.iconDate}/>
|
||||
<PubDate
|
||||
className={styles.pubdate}
|
||||
created_at={props.comment.created_at}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
.button {
|
||||
composes: buttonReset from "coral-framework/styles/reset.css";
|
||||
margin: 5px 10px 5px 0px;
|
||||
}
|
||||
|
||||
@media (max-width: 425px) {
|
||||
.label {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
padding: 0 2px;
|
||||
font-size: 12px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
@@ -2,17 +2,20 @@ import React, {PropTypes} from 'react';
|
||||
|
||||
import t from 'coral-framework/services/i18n';
|
||||
|
||||
import classnames from 'classnames';
|
||||
import cn from 'classnames';
|
||||
import styles from './ReplyButton.css';
|
||||
|
||||
const name = 'coral-plugin-replies';
|
||||
|
||||
const ReplyButton = ({onClick}) => {
|
||||
return (
|
||||
<button
|
||||
className={classnames(`${name}-reply-button`)}
|
||||
className={cn(`${name}-reply-button`, styles.button)}
|
||||
onClick={onClick}>
|
||||
{t('reply')}
|
||||
<i className={`${name}-icon material-icons`}
|
||||
<span className={cn(`${name}-label`, styles.label)}>
|
||||
{t('reply')}
|
||||
</span>
|
||||
<i className={cn(`${name}-icon`, 'material-icons', styles.icon)}
|
||||
aria-hidden={true}>reply</i>
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
|
||||
.root {
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
import React, {PropTypes} from 'react';
|
||||
import {Icon as IconMDL} from 'react-mdl';
|
||||
import cn from 'classnames';
|
||||
import styles from './Icon.css';
|
||||
|
||||
const Icon = ({className = '', name}) => (
|
||||
<IconMDL className={className} name={name} />
|
||||
<IconMDL className={cn(styles.root, className)} name={name} />
|
||||
);
|
||||
|
||||
Icon.propTypes = {
|
||||
|
||||
@@ -1,9 +1,70 @@
|
||||
li.base--active {
|
||||
background: white;
|
||||
font-weight: bold;
|
||||
.root {
|
||||
display: inline-block;
|
||||
margin-right: -1px;
|
||||
margin-bottom: -1px;
|
||||
}
|
||||
|
||||
li.material--active {
|
||||
font-weight: bold;
|
||||
border-bottom: solid 2px black;
|
||||
.rootActive {
|
||||
|
||||
}
|
||||
|
||||
.rootSub {
|
||||
display: inline-block;
|
||||
margin-bottom: -2px;
|
||||
}
|
||||
|
||||
.rootSubActive {
|
||||
|
||||
}
|
||||
|
||||
.button {
|
||||
composes: buttonReset from "coral-framework/styles/reset.css";
|
||||
padding: 8px 10px;
|
||||
color: #4E5259;
|
||||
border: solid 1px #D8D8D8;
|
||||
background: #F0F0F0;
|
||||
border-top-left-radius: 5px;
|
||||
border-top-right-radius: 5px;
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.button:hover, .button:focus {
|
||||
background: #d5d5d5;
|
||||
border-bottom: 1px solid #d5d5d5;
|
||||
}
|
||||
|
||||
.buttonActive, .buttonActive:hover, .buttonActive:focus {
|
||||
background: white;
|
||||
font-weight: bold;
|
||||
border-bottom: 1px solid white;
|
||||
}
|
||||
|
||||
.buttonSub {
|
||||
composes: buttonReset from "coral-framework/styles/reset.css";
|
||||
color: black;
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 6px 12px;
|
||||
margin-bottom: 3px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.buttonSub:hover, .buttonSub:focus {
|
||||
background: transparent;
|
||||
border-bottom: solid 3px #d5d5d5;
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
|
||||
.buttonSubActive, .buttonSubActive:hover, .buttonSubActive:focus {
|
||||
font-weight: bold;
|
||||
border-bottom: solid 3px #10589b;
|
||||
margin-bottom: 0px;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
|
||||
.root:only-child .button, .rootSub:only-child .buttonSub {
|
||||
cursor: default;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,109 @@
|
||||
import React from 'react';
|
||||
import styles from './Tab.css';
|
||||
import cn from 'classnames';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
export default ({children, tabId, active, onTabClick, cStyle = 'base', ...props}) => (
|
||||
<li
|
||||
key={tabId}
|
||||
className={`${active ? `${styles[`${cStyle}--active`]} talk-tab-active` : ''} talk-tab ${props.className}`}
|
||||
onClick={() => onTabClick(tabId)}
|
||||
>
|
||||
{children}
|
||||
</li>
|
||||
);
|
||||
/**
|
||||
* The `Tab` component is used inside the `TabBar` Component, to
|
||||
* render tabs.
|
||||
*/
|
||||
class Tab extends React.Component {
|
||||
handleTabClick = () => {
|
||||
if (this.props.onTabClick) {
|
||||
this.props.onTabClick(this.props.tabId);
|
||||
}
|
||||
}
|
||||
|
||||
getRootClassName({active, className, sub, classNames = {}} = this.props) {
|
||||
return cn(
|
||||
'talk-tab',
|
||||
className,
|
||||
{
|
||||
[classNames.root || styles.root]: !sub,
|
||||
[classNames.rootSub || styles.rootSub]: sub,
|
||||
[classNames.rootActive || styles.rootActive]: active && !sub,
|
||||
[classNames.rootSubActive || styles.rootSubActive]: active && sub,
|
||||
'talk-tab-active': active,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
getButtonClassName({sub, active, classNames = {}} = this.props) {
|
||||
return cn(
|
||||
'talk-tab-button',
|
||||
{
|
||||
[classNames.button || styles.button]: !sub,
|
||||
[classNames.buttonSub || styles.buttonSub]: sub,
|
||||
[classNames.buttonActive || styles.buttonActive]: active && !sub,
|
||||
[classNames.buttonSubActive || styles.buttonSubActive]: active && sub,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
children,
|
||||
classNames: _a,
|
||||
active,
|
||||
onTabClick: _c,
|
||||
tabId: _d,
|
||||
sub: _e,
|
||||
'aria-controls': ariaControls,
|
||||
...rest,
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<li
|
||||
{...rest}
|
||||
role="presentation"
|
||||
className={this.getRootClassName()}
|
||||
>
|
||||
<button
|
||||
aria-controls={ariaControls}
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
className={this.getButtonClassName()}
|
||||
onClick={this.handleTabClick}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Tab.propTypes = {
|
||||
|
||||
// className to be added to the root element.
|
||||
className: PropTypes.string,
|
||||
|
||||
// classNames allows full design customization of the component.
|
||||
classNames: PropTypes.shape({
|
||||
root: PropTypes.string,
|
||||
rootActive: PropTypes.string,
|
||||
rootSub: PropTypes.string,
|
||||
rootSubActive: PropTypes.string,
|
||||
button: PropTypes.string,
|
||||
buttonActive: PropTypes.string,
|
||||
buttonSub: PropTypes.string,
|
||||
buttonSubActive: PropTypes.string,
|
||||
}),
|
||||
|
||||
// active indicates that this tab is currently active.
|
||||
// This is injected by the `TabBar` component.
|
||||
active: PropTypes.bool,
|
||||
|
||||
// onTabClick is fired whenever the tab was clicked. The tabId is passed as
|
||||
// the first argument.
|
||||
onTabClick: PropTypes.func,
|
||||
|
||||
// Sub indicates that this is a tab of a sub-tab-panel.
|
||||
// This is injected by the `TabBar` component.
|
||||
sub: PropTypes.bool,
|
||||
|
||||
// `aria-controls` should be set to the `id` of the `TabContent` for accessibility.
|
||||
// This is injected by the `TabBar` component.
|
||||
'aria-controls': PropTypes.string,
|
||||
};
|
||||
|
||||
export default Tab;
|
||||
|
||||
@@ -1,44 +1,14 @@
|
||||
.base {
|
||||
.root {
|
||||
list-style: none;
|
||||
border-bottom: solid 1px #D8D8D8;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.base li {
|
||||
color: #4E5259;
|
||||
border: solid 1px #D8D8D8;
|
||||
background: #F0F0F0;
|
||||
border-top-left-radius: 5px;
|
||||
border-top-right-radius: 5px;
|
||||
display: inline-block;
|
||||
border-bottom: none;
|
||||
padding: 8px 10px;
|
||||
margin-right: -1px;
|
||||
user-select: none;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.base li:hover {
|
||||
background: #d5d5d5;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.material {
|
||||
.rootSub {
|
||||
list-style: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.material li {
|
||||
color: black;
|
||||
border: none;
|
||||
border-bottom: solid 2px white;
|
||||
background: white;
|
||||
padding: 8px 0;
|
||||
margin-right: 40px;
|
||||
}
|
||||
|
||||
.material li:hover {
|
||||
background: white;
|
||||
border-bottom: solid 2px grey;
|
||||
margin: 0;
|
||||
border-bottom: solid 2px #eee;
|
||||
}
|
||||
|
||||
@@ -1,37 +1,86 @@
|
||||
import React from 'react';
|
||||
import styles from './TabBar.css';
|
||||
import cn from 'classnames';
|
||||
import Tab from './Tab';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
/**
|
||||
* The `TabBar` component accepts `Tab` components to create
|
||||
* a tab bar.
|
||||
*/
|
||||
class TabBar extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.handleClickTab = this.handleClickTab.bind(this);
|
||||
}
|
||||
|
||||
handleClickTab(tabId) {
|
||||
if (this.props.onChange) {
|
||||
this.props.onChange(tabId);
|
||||
}
|
||||
getRootClassName({className, classNames = {}, sub} = this.props) {
|
||||
return cn(
|
||||
'talk-tab-bar',
|
||||
className,
|
||||
{
|
||||
[classNames.root || styles.root]: !sub,
|
||||
[classNames.rootSub || styles.rootSub]: sub,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const {children, activeTab, cStyle = 'base'} = this.props;
|
||||
const {
|
||||
children,
|
||||
activeTab,
|
||||
tabClassNames,
|
||||
classNames: _a,
|
||||
onTabClick: _b,
|
||||
'aria-controls': ariaControls,
|
||||
sub,
|
||||
...rest,
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ul className={`${styles.base} ${cStyle ? styles[cStyle] : ''} talk-tab-bar ${this.props.className}`}>
|
||||
{React.Children.toArray(children)
|
||||
.filter((child) => !child.props.restricted)
|
||||
.map((child, tabId) =>
|
||||
React.cloneElement(child, {
|
||||
tabId,
|
||||
active: child.props.id === activeTab,
|
||||
onTabClick: this.handleClickTab,
|
||||
cStyle
|
||||
})
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
<ul
|
||||
{...rest}
|
||||
role="tablist"
|
||||
className={this.getRootClassName()}
|
||||
>
|
||||
{React.Children.toArray(children)
|
||||
.map((child, i) =>
|
||||
React.cloneElement(child, {
|
||||
tabId: (child.props.tabId !== undefined) ? child.props.tabId : i,
|
||||
active: child.props.tabId === activeTab,
|
||||
onTabClick: this.props.onTabClick,
|
||||
classNames: tabClassNames,
|
||||
'aria-controls': ariaControls,
|
||||
sub,
|
||||
})
|
||||
)}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
TabBar.propTypes = {
|
||||
|
||||
// className to be added to the root element.
|
||||
className: PropTypes.string,
|
||||
|
||||
// classNames allows full design customization of the component.
|
||||
classNames: PropTypes.shape({
|
||||
root: PropTypes.string,
|
||||
rootSub: PropTypes.string,
|
||||
}),
|
||||
|
||||
// classNames to be passed to the children.
|
||||
tabClassNames: Tab.propTypes.classNames,
|
||||
|
||||
// activeTab should be set to the currently active tabId.
|
||||
activeTab: PropTypes.string,
|
||||
|
||||
// onTabClick is fired whenever the tab was clicked. The tabId is passed as
|
||||
// the first argument.
|
||||
onTabClick: PropTypes.func,
|
||||
|
||||
// Sub indicates that this is a sub-tab-panel.
|
||||
sub: PropTypes.bool,
|
||||
|
||||
// `aria-controls` should be set to the `id` of the `TabContent` for accessibility.
|
||||
'aria-controls': PropTypes.string,
|
||||
};
|
||||
|
||||
export default TabBar;
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
.root {
|
||||
padding-top: 10px;
|
||||
}
|
||||
@@ -1,6 +1,43 @@
|
||||
import React from 'react';
|
||||
import cn from 'classnames';
|
||||
import PropTypes from 'prop-types';
|
||||
import styles from './TabContent.css';
|
||||
|
||||
export default ({children, show = true}) => (
|
||||
show ? <div>{children}</div> : null
|
||||
function getRootClassName(className) {
|
||||
return cn('talk-tab-content', className, styles.root);
|
||||
}
|
||||
|
||||
/**
|
||||
* The `TabContent` component accepts `TabPane` components to render
|
||||
* the content of a `Tab`.
|
||||
*/
|
||||
const TabContent = ({children, className, activeTab, sub, ...rest}) => (
|
||||
<div
|
||||
{...rest}
|
||||
className={getRootClassName(className)}
|
||||
>
|
||||
{
|
||||
React.Children.toArray(children)
|
||||
.filter((child) => child.props.tabId === activeTab)
|
||||
.map((child, i) =>
|
||||
React.cloneElement(child, {
|
||||
tabId: (child.props.tabId !== undefined) ? child.props.tabId : i,
|
||||
sub,
|
||||
}))
|
||||
}
|
||||
</div>
|
||||
);
|
||||
|
||||
TabContent.propTypes = {
|
||||
|
||||
// className to be added to the root element.
|
||||
className: PropTypes.string,
|
||||
|
||||
// activeTab should be set to the currently active tabId.
|
||||
activeTab: PropTypes.string,
|
||||
|
||||
// Sub indicates that this component belongs to a sub-tab-panel.
|
||||
sub: PropTypes.bool,
|
||||
};
|
||||
|
||||
export default TabContent;
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
|
||||
.root, .rootSub {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
top: -2px;
|
||||
background: #616161;
|
||||
color: white;
|
||||
font-weight: normal;
|
||||
font-size: 10px;
|
||||
padding: 2px;
|
||||
margin-left: 2px;
|
||||
margin-top: -2px;
|
||||
min-width: 20px;
|
||||
}
|
||||
|
||||
.rootSubActive {
|
||||
background: #10589b;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import React from 'react';
|
||||
import cn from 'classnames';
|
||||
import styles from './TabCount.css';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
function getNumber(no) {
|
||||
let result = Number.parseInt(no);
|
||||
if (no >= 1000) {
|
||||
result = `${Math.round(result / 100) / 10}k`;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function getRootClassName({className, active, sub}) {
|
||||
return cn(
|
||||
'talk-tab-count',
|
||||
className,
|
||||
{
|
||||
[styles.root]: !sub,
|
||||
[styles.rootSub]: sub,
|
||||
[styles.rootActive]: active && !sub,
|
||||
[styles.rootSubActive]: active && sub,
|
||||
'talk-tab-active': active,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The `TabCount` renders a count number next to a tab name.
|
||||
*/
|
||||
const TabCount = ({children, active, sub, className}) => (
|
||||
<span className={getRootClassName({className, active, sub})}>{getNumber(children)}</span>
|
||||
);
|
||||
|
||||
TabCount.propTypes = {
|
||||
|
||||
// className to be added to the root element.
|
||||
className: PropTypes.string,
|
||||
|
||||
// active indicates that the related tab is currently active.
|
||||
active: PropTypes.bool,
|
||||
|
||||
// Sub indicates that this component belongs to a sub-tab-panel.
|
||||
sub: PropTypes.bool,
|
||||
};
|
||||
|
||||
export default TabCount;
|
||||
@@ -0,0 +1,34 @@
|
||||
import React from 'react';
|
||||
import cn from 'classnames';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
function getRootClassName(className) {
|
||||
return cn('talk-pane', className);
|
||||
}
|
||||
|
||||
/**
|
||||
* The `TabPane` component is used inside the `TabContent` component to render
|
||||
* the content of a `Tab`.
|
||||
*/
|
||||
const TabPane = ({children, className, tabId: _a, sub: _b, ...rest}) => (
|
||||
<div
|
||||
{...rest}
|
||||
className={getRootClassName(className)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
TabPane.propTypes = {
|
||||
|
||||
// className to be added to the root element.
|
||||
className: PropTypes.string,
|
||||
|
||||
tabId: PropTypes.string,
|
||||
|
||||
// Sub indicates that this component belongs to a sub-tab-panel.
|
||||
// This is injected by the `TabContent` component.
|
||||
sub: PropTypes.bool,
|
||||
};
|
||||
|
||||
export default TabPane;
|
||||
@@ -4,7 +4,9 @@ export {default as CoralLogo} from './components/CoralLogo';
|
||||
export {default as FabButton} from './components/FabButton';
|
||||
export {default as TabBar} from './components/TabBar';
|
||||
export {default as Tab} from './components/Tab';
|
||||
export {default as TabCount} from './components/TabCount';
|
||||
export {default as TabContent} from './components/TabContent';
|
||||
export {default as TabPane} from './components/TabPane';
|
||||
export {default as Button} from './components/Button';
|
||||
export {default as Spinner} from './components/Spinner';
|
||||
export {default as Tooltip} from './components/Tooltip';
|
||||
|
||||
@@ -77,7 +77,15 @@ const CONFIG = {
|
||||
SMTP_HOST: process.env.TALK_SMTP_HOST,
|
||||
SMTP_PASSWORD: process.env.TALK_SMTP_PASSWORD,
|
||||
SMTP_PORT: process.env.TALK_SMTP_PORT,
|
||||
SMTP_USERNAME: process.env.TALK_SMTP_USERNAME
|
||||
SMTP_USERNAME: process.env.TALK_SMTP_USERNAME,
|
||||
|
||||
//------------------------------------------------------------------------------
|
||||
// Flagging Config
|
||||
//------------------------------------------------------------------------------
|
||||
|
||||
// DISABLE_AUTOFLAG_SUSPECT_WORDS is true when the suspect words that are
|
||||
// matched should not be flagged.
|
||||
DISABLE_AUTOFLAG_SUSPECT_WORDS: process.env.TALK_DISABLE_AUTOFLAG_SUSPECT_WORDS === 'TRUE'
|
||||
};
|
||||
|
||||
//==============================================================================
|
||||
|
||||
@@ -36,6 +36,28 @@ entries:
|
||||
- title: Setup
|
||||
url: /install-setup.html
|
||||
output: web
|
||||
- title: Microservice Deployments
|
||||
url: /install-microservices.html
|
||||
output: web
|
||||
- title: Troubleshooting
|
||||
url: /install-troubleshooting.html
|
||||
output: web
|
||||
|
||||
- title: Architecture
|
||||
output: web
|
||||
folderitems:
|
||||
- title: Overview
|
||||
url: /architecture.html
|
||||
output: web
|
||||
- title: Tags
|
||||
url: /architecture-tags.html
|
||||
output: web
|
||||
- title: Metadata API
|
||||
url: /architecture-metadata.html
|
||||
output: web
|
||||
- title: cli
|
||||
url: /architecture-cli.html
|
||||
output: web
|
||||
|
||||
- title: Plugins
|
||||
output: web
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
---
|
||||
title: The Talk cli
|
||||
keywords: architecture
|
||||
sidebar: talk_sidebar
|
||||
permalink: architecture-cli.html
|
||||
summary:
|
||||
---
|
||||
|
||||
Talk ships with a cli tool that allows access to a wide variety of functionality.
|
||||
|
||||
It is designed to provide a convenient way for engineers to perform key tasks without the need to muck about in the UI. It also opens the door for scripts to perform operations programmatically.
|
||||
|
||||
Note: the cli tool requires [the Talk environment to be configured](configuration.html) either via env vars or by using a `.cli` file via `bin/cli -c .env [command] ....`
|
||||
|
||||
## Using the cli
|
||||
|
||||
In a terminal, try:
|
||||
|
||||
```
|
||||
/path/to/talk/bin cli [options] [commands] [arguments]
|
||||
```
|
||||
|
||||
Commonly, you'll be in the `talk/` folder, in which case you can:
|
||||
|
||||
```
|
||||
bin/cli [options] [commands] [arguments]
|
||||
```
|
||||
|
||||
If you're a heavy cli user, consider adding the `bin` folder to your PATH so you can run `cli` from anywhere!
|
||||
|
||||
If you are using [our Docker environment](install-docker.html), the bin folder will already be in the PATH.
|
||||
|
||||
## What can I do with the cli?
|
||||
|
||||
The Talk cli ships with 'unix style' help. To access the docs, simply run the cli with insufficient arguments.
|
||||
|
||||
Let's say I wanted to figure out how to change a user's password. I'd start be seeing what the cli has for me.
|
||||
|
||||
(Note: the following output may change, please reference at the `--help` output for your version as you use the cli.)
|
||||
|
||||
```
|
||||
talk :) ]$ bin/cli --help
|
||||
|
||||
Usage: cli [options] [command]
|
||||
|
||||
|
||||
Commands:
|
||||
|
||||
serve serve the application
|
||||
settings interact with the application settings
|
||||
assets interact with assets
|
||||
setup setup the application
|
||||
jobs work with the job queues
|
||||
token work with the access tokens
|
||||
users work with the application auth
|
||||
migration provides utilities for migrating the database
|
||||
plugins provides utilities for interacting with the plugin system
|
||||
help [cmd] display help for [cmd]
|
||||
|
||||
Options:
|
||||
|
||||
-h, --help output usage information
|
||||
-V, --version output the version number
|
||||
-c, --config [path] Specify the configuration file to load
|
||||
--pid [path] Specify a path to output the current PID to
|
||||
```
|
||||
|
||||
Most commands contain sub-commands. Running with cli with such a command generates the docs for the sub-commands and options therein.
|
||||
|
||||
Change user password is likely to be in the `users` command group.
|
||||
|
||||
```
|
||||
talk :) ]$ bin/cli users
|
||||
|
||||
Usage: cli-users [options] [command]
|
||||
|
||||
|
||||
Commands:
|
||||
|
||||
create [options] create a new user
|
||||
delete <userID> delete a user
|
||||
passwd <userID> change a password for a user
|
||||
update [options] <userID> update a user
|
||||
list list all the users in the database
|
||||
merge <dstUserID> <srcUserID> merge srcUser into the dstUser
|
||||
addrole <userID> <role> adds a role to a given user
|
||||
removerole <userID> <role> removes a role from a given user
|
||||
ban <userID> ban a given user
|
||||
uban <userID> unban a given user
|
||||
disable <userID> disable a given user from logging in
|
||||
enable <userID> enable a given user from logging in
|
||||
|
||||
Options:
|
||||
|
||||
-h, --help output usage information
|
||||
-V, --version output the version number
|
||||
-c, --config [path] Specify the configuration file to load
|
||||
--pid [path] Specify a path to output the current PID to
|
||||
```
|
||||
|
||||
I now see that I can change a password like so:
|
||||
|
||||
```
|
||||
bin/cli users passwd [userID]
|
||||
```
|
||||
|
||||
You can also read these help prompts by [exploring the source code](https://github.com/coralproject/talk/blob/master/bin/cli).
|
||||
|
||||
### Usage Notes
|
||||
|
||||
If you haven't used a cli enabled system before, think of it like this: generally, you'd make a rest call, rpc, etc... to perform an action. The cli's api is designed in the same way, just for the audience of command line wielding engineers and scripts.
|
||||
|
||||
The best way to understand what the cli does is to explore the help commands. Uses of cli are also scattered through this documentation in context of their topics.
|
||||
|
||||
For some real world uses of the cli, see the scripts in the [package.json file](https://github.com/coralproject/talk/blob/d688f70c19d8dee48371784009fd07322dae4eb5/package.json#L8).
|
||||
|
||||
## What's really going on when I run the cli?
|
||||
|
||||
The cli tool is a standalone application. Running it starts up the internals of a talk process, executes the given command, then shuts it down. No server functionality is enabled by cli commands unless specifically noted.
|
||||
|
||||
The cli tool _does not require a talk server to be running._ This means that you can execute commands, for example, during and installation process before starting the server. The also means that you can execute commands using varying configurations (via the `-c [.env file]` flag).
|
||||
|
||||
### Accessing existing Talk installs with the cli
|
||||
|
||||
You may use the cli tool to 'remotely' control existing talk installs.
|
||||
|
||||
This is accomplished by running the cli tool on any box using the mongo/redis/etc... credentials for the environment that you would like to act on. For example, if you want something to happen periodically on your production Talk install, you could set up a utility box with a cron job that triggers the cli with the same db/cache credentials. If you want to do something quick on a staging server, you could run the cli locally with staging credentials.
|
||||
|
||||
The cli tool will connect directly with the install's db and redis instance(s) so ensure that your box can reach those servers on the appropriate ports.
|
||||
|
||||
Also, _please ensure your cli and the server(s) in an environment are using the same version of Talk._
|
||||
|
||||
Please secure your environments and credentials or the cli tool becomes a convenient way for someone to own your system.
|
||||
|
||||
## Extending the cli
|
||||
|
||||
The Talk cli is based on the excellent [commander](https://github.com/tj/commander.js/) library.
|
||||
|
||||
At the time of writing, there are no plugin hooks for the cli tool. If you would like to change this, whether by writing code yourself or recommending a need, please [write and issue](https://github.com/coralproject/talk/blob/053b687959d45bcd682a1a2a4b604ebfab7441bb/CONTRIBUTING.md#writing-code).
|
||||
@@ -0,0 +1,92 @@
|
||||
---
|
||||
title: Metadata
|
||||
keywords: architecture
|
||||
sidebar: talk_sidebar
|
||||
permalink: architecture-metadata.html
|
||||
summary:
|
||||
---
|
||||
|
||||
_Metadata_ allows you to add fields to models that are not represented in the core schema.
|
||||
|
||||
## Goals
|
||||
|
||||
The metadata api is designed to satisfy two product goals:
|
||||
|
||||
* Give developers flexibility in extending datatypes.
|
||||
* Protect core fields that are essential to Talk's operation.
|
||||
|
||||
## Design
|
||||
|
||||
Metadata is represented by an [subdocument in our Schemas](https://github.com/coralproject/talk/blob/c59c09e1f42c51eed3b0d57b7c2882fc7b5edc13/models/comment.js#L74). This takes advantage of Mongo's flexibility allowing for any data to be stored therein.
|
||||
|
||||
### Setting Metadata
|
||||
|
||||
Talk provides [a service layer](https://github.com/coralproject/talk/blob/c59c09e1f42c51eed3b0d57b7c2882fc7b5edc13/services/metadata.js) allowing developers to `set` and `unset` metadata on objects in a way similar to key-value stores.
|
||||
|
||||
Let's say that I want to add a custom field called `potency` to a comment.
|
||||
|
||||
```
|
||||
const MetadataService = require('services/metadata');
|
||||
const CommentModel = require('models/comment');
|
||||
|
||||
// Sets the property `potency` on the comment with `id=1`.
|
||||
MetadataService.set(CommentModel, '1', 'potency', 42);
|
||||
```
|
||||
|
||||
Note that the model passed here is the Model itself and not an individual comment object. This allows us to update the value on that document [in an atomic manner](https://github.com/coralproject/talk/blob/c59c09e1f42c51eed3b0d57b7c2882fc7b5edc13/services/metadata.js#L60) for efficiency and to prevent race conditions.
|
||||
|
||||
### Accessing Metadata
|
||||
|
||||
The metadata api does not contain a `get` method. The metadata object is retrieved via database queries along with the rest of the data.
|
||||
|
||||
## Metadata and the Graph
|
||||
|
||||
One of the first principles of GraphQL is that the shape of the graph does not need to be the same as the shape of the data in the database. In fact, it probably shouldn't be.
|
||||
|
||||
This enables us to treat metadata fields in any way that makes sense as we design our Graph. The fact that a value is stored in the metadata object is an implementation detail invisible to the front end.
|
||||
|
||||
Take for example, the `reason` field in the `FlagAction` type. This stores the user provided reason why they flagged a comment. As far as the front end knows, it's [just another field](https://github.com/coralproject/talk/blob/c59c09e1f42c51eed3b0d57b7c2882fc7b5edc13/graph/typeDefs.graphql#L453) alongside the core fields:
|
||||
|
||||
```
|
||||
# graph/typeDefs.graphql
|
||||
type FlagAction implements Action {
|
||||
|
||||
...
|
||||
|
||||
# The reason for which the Flag Action was created.
|
||||
reason: String
|
||||
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
If, however, we [look at the resolver](https://github.com/coralproject/talk/blob/a47e2378e96f34f25447782f3e7ce59fa48ec791/graph/resolvers/dont_agree_action.js) for that field, we see that `reason` is [destructured](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Destructuring_assignment) from the metadata object and returned.
|
||||
|
||||
```
|
||||
// graph/resolvers/dont_agree_action.js
|
||||
const DontAgreeAction = {
|
||||
|
||||
// Stored in the metadata, extract and return.
|
||||
reason({metadata: {reason}}) {
|
||||
return reason;
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = DontAgreeAction;
|
||||
```
|
||||
|
||||
This is an extremely powerful pattern as it allows us absolute freedom in designing our graph and complete isolation of the added fields in the database.
|
||||
|
||||
## Some things to keep in mind
|
||||
|
||||
### Namespace your metadata fields
|
||||
|
||||
Since metadata can be added by the core and multiple plugins, collisions may occur. As you create your plugins, please be careful to pick unique names for metadata fields. We recommend namespacing all your fields in a subdocument named after your plugin.
|
||||
|
||||
```
|
||||
[model].metadata.[your_plugin_name].[the_field]
|
||||
```
|
||||
|
||||
### Querying by metadata fields
|
||||
|
||||
We currently do not have a clean way to index metadata fields. As a result queries that match against metadata fields will not scale. If you have a need to match, sort, etc... by a metadata field, [please let us know](https://github.com/coralproject/talk/blob/master/CONTRIBUTING.md#writing-code).
|
||||
@@ -0,0 +1,71 @@
|
||||
---
|
||||
title: Tags
|
||||
keywords: architecture
|
||||
sidebar: talk_sidebar
|
||||
permalink: architecture-tags.html
|
||||
summary:
|
||||
---
|
||||
|
||||
Tags are essentially strings that can be added to models. Currently, tags can be added to [Users, Comments and Assets](https://github.com/coralproject/talk/blob/ced449a1489d47c25d604020fa2e0b3b7a741353/graph/typeDefs.graphql#L144). If you would like to add tags to other models, you can extend this schema using [GraphQL hooks](plugins-server.html#graphql-hooks).
|
||||
|
||||
## Tag Definitions
|
||||
|
||||
When handling tags, the Talk Server references a set of definitions that describe how tags are handled. These definitions are keyed off the tag `name`, the simple string that is stored on items.
|
||||
|
||||
The schema for Tag definitions [can be found here](https://github.com/coralproject/talk/blob/3545bf01cd91044fdb738d337a0ac94d9f71fbc3/models/schema/tag.js).
|
||||
|
||||
Note that along with the `name`, tag definitions contains:
|
||||
|
||||
* `permissions` information about who can see and set the tag,
|
||||
* `models` which `ITEM_TYPES` this tag can be applied to, and
|
||||
|
||||
Whenever a tag is 'handled' by the server, it references this definition to determine that tag's behavior.
|
||||
|
||||
See [Plugin API Documentation](plugins-server.html#field-tags) for more information.
|
||||
|
||||
### Creating a Tag Definition
|
||||
|
||||
Tag Definitions must be created in order for the system to determine what tags are permitted on the server side.
|
||||
|
||||
Tag Definitions do not contain any logic themselves but provide information that other parts of the system can use to specify which models a tag can be applied to (models) and perform authorization logic (permissions).
|
||||
|
||||
Take the tag created by `coral-plugin-offtopic` as an example.
|
||||
|
||||
```
|
||||
// coral-plugin-offtopic/index.js
|
||||
module.exports = {
|
||||
tags: [
|
||||
{
|
||||
name: 'OFF_TOPIC',
|
||||
permissions: {
|
||||
public: true,
|
||||
self: true,
|
||||
roles: []
|
||||
},
|
||||
models: ['COMMENTS'],
|
||||
created_at: new Date()
|
||||
}
|
||||
]
|
||||
};
|
||||
```
|
||||
|
||||
This plugin allows users to self-report that their comment is "off topic" at the time of creation, then display a badge on those comments.
|
||||
|
||||
To accomplish this, the plugin creates the tag `OFF_TOPIC` with:
|
||||
|
||||
* `permissions.public: true` - will be sent over the wire to the client side
|
||||
* `permissions.self: true` - can be added by the active user to themselves or assets they own
|
||||
* `permissions.roles: []` - cannot be added by anyone based on their roles
|
||||
* `models: ['COMMENTS']` - can only be added to COMMENTS (not to users/assets/etc...)
|
||||
|
||||
And [viola](https://youtu.be/Q0O9gFf-tiI?t=23s)! This tag is something that can only be created by the logged in user on their own comments and is sent over the wire to the client so it can display the badge.
|
||||
|
||||
## Tag Links
|
||||
|
||||
When tags are stored on objects in the database, they are represented by [TagLinks](https://github.com/coralproject/talk/blob/master/models/schema/tag_link.js).
|
||||
|
||||
A TagLinks says that `tag` was `assigned_by` a specific user at a specific time (`created_at`).
|
||||
|
||||
Note that the `tag` field in the TagLinkSchema is the full TagSchema itself. This allows for another level of flexibility. Server code may generate Tags on the fly, complete with programmatically generated permissions and item behaviors.
|
||||
|
||||
If a Tag definitions exists in the global/asset context then that definition will be used regardless of what is stored here. This allows high level controls on the behavior of tags, ensuring that plugins cannot produce unexpected definitions for already defined tags.
|
||||
@@ -0,0 +1,80 @@
|
||||
---
|
||||
title: Architecture Overview
|
||||
keywords: architecture
|
||||
sidebar: talk_sidebar
|
||||
permalink: architecture.html
|
||||
summary:
|
||||
---
|
||||
|
||||
## Talk's Architecture
|
||||
|
||||
Talk consists of four distinct layers of code:
|
||||
|
||||
* Plugins
|
||||
* Plugin API
|
||||
* Core
|
||||
* cli
|
||||
|
||||
### Plugins
|
||||
|
||||
Talk plugins deliver the features and functionality that can be changed or removed. Much of the default functionality is delivered by core plugins allowing developers to have control over any non-essential functionality.
|
||||
|
||||
### Plugin API
|
||||
|
||||
Talk plugins interact exclusively with the Plugin API. Maintaining this layer of separation between plugins and core allows us to consciously design the api that we want it publish to plugin authors. We can then expose just the elements of core that make sense and maintain that contract as core changes.
|
||||
|
||||
### Core
|
||||
|
||||
Talk core consists of architecture and functionality that deliver stability, security, scalability, extendability, etc... In addition, the Core contains features and functionality that is essential to the operation of Talk as a product.
|
||||
|
||||
Our goal is to continually extend our plugin infrastructure making the Core as pluggable as possible. Ultimately, a day may come where the Core of Talk is simply a framework for delivering a certain flavor of web applications.
|
||||
|
||||
### cli
|
||||
|
||||
Talk ships with [a cli tool](architecture-cli.html) that exposes functionality to the command line. We seek to provide cli functionality for all features that could need to be accomplished programmatically or prior to the server's startup.
|
||||
|
||||
## Thinking about Plugins, the Plugin API and Core?
|
||||
|
||||
The following is a template for a thought process that may help clarify your ideas against the backdrop of Talk's architecture.
|
||||
|
||||
Think of a feature or capability. It could be something that's already in Talk or not. It could be something you want to build, or something you'd think would be a terrible idea. The important part here is to have something to interrogate.
|
||||
|
||||
```
|
||||
wait(60000);
|
||||
```
|
||||
|
||||
Now, ask these questions:
|
||||
|
||||
### Is it a Plugin?
|
||||
|
||||
Most work for Talk happens in the Plugin space. If the answers to any of these questions is Yes, then you're thinking of a Plugin.
|
||||
|
||||
* Does Talk's existing Plugin APIs support the thing you want to build?
|
||||
* Is this something that only some users will want/need?
|
||||
* Is this something that we want devs to iterate on widely?
|
||||
|
||||
You should [build it as a plugin](plugins-quickstart.html). Feel free to explore here on your own or reach out to us. We love to advise on plugins, so please feel free to [file an issue](https://github.com/coralproject/talk/blob/master/CONTRIBUTING.md) and we will start a conversation. We will help you conceptualize, architect and promote your plugin if it is in line with our values.
|
||||
|
||||
### Does it need updates to the Plugin API?
|
||||
|
||||
If you answered yes above:
|
||||
|
||||
* Do I need to extend the Plugin API to support my plugin?
|
||||
|
||||
Often times all the functionality a plugin needs is in the Core, but the Plugin API doesn't expose it. In these cases, we seek to iteratively extend the Talk Plugin API. All Plugin API contributions from the community must begin by [filing an Issue](https://github.com/coralproject/talk/blob/master/CONTRIBUTING.md).
|
||||
|
||||
Note: we are stabilizing the process by which new Plugin API bindings are created, agreed upon and ultimately made part of our Plugins Contract. If you are interested in this process, please reach out to us.
|
||||
|
||||
### Does it require updates to the Plugin API _and_ Core?
|
||||
|
||||
What, if any, changes need to be made to Core so that the API can be extended?
|
||||
|
||||
Quite often the only things missing from Core are things like _events_, _slots_, _CSS classes_, etc... Adding these is a great way to become a Core Contributor and break new ground as a Plugin Developer.
|
||||
|
||||
We seek to keep Core as lean as possible.
|
||||
|
||||
### Is my idea really just Core?
|
||||
|
||||
Amazing! We are always looking to extend the capabilities of Talk. We look forward to discussing what you've got to bring!
|
||||
|
||||
Please see our [contributing guide](](https://github.com/coralproject/talk/blob/master/CONTRIBUTING.md)) for more information.
|
||||
@@ -17,7 +17,7 @@ sign and verify tokens via a `HS256` algorithm.
|
||||
- `TALK_JWT_EXPIRY` (_optional_) - the expiry duration (`exp`) for the tokens issued for logged in sessions (Default `1 day`)
|
||||
- `TALK_JWT_ISSUER` (_optional_) - the issuer (`iss`) claim for login JWT tokens (Default `process.env.TALK_ROOT_URL`)
|
||||
- `TALK_JWT_AUDIENCE` (_optional_) - the audience (`aud`) claim for login JWT tokens (Default `talk`)
|
||||
- `TALK_SMTP_EMAIL` (*required for email*) - the address to send emails from using the
|
||||
- `TALK_SMTP_FROM_ADDRESS` (*required for email*) - the address to send emails from using the
|
||||
SMTP provider.
|
||||
- `TALK_SMTP_USERNAME` (*required for email*) - username of the SMTP provider you are using.
|
||||
- `TALK_SMTP_PASSWORD` (*required for email*) - password for the SMTP provider you are using.
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
---
|
||||
title: Microservice Deployments
|
||||
keywords: install
|
||||
sidebar: talk_sidebar
|
||||
permalink: install-microservices.html
|
||||
summary:
|
||||
---
|
||||
|
||||
In Talk, we seek to deliver the simplicity of a monolith with the advantages of
|
||||
a microservice based infrastructure for those who want them.
|
||||
|
||||
To accomplish this, Talk has the ability to run with subsets of its overall
|
||||
functionality and contains architecture that allows them to operate logically as
|
||||
microservices when running in a single environment.
|
||||
|
||||
## Talk Server Functionalities
|
||||
|
||||
The Talk server serves several logically/architecturally distinct functions:
|
||||
|
||||
* A web server that
|
||||
* serves "public" assets (aka, the comment embed)
|
||||
* the GraphQL endpoints
|
||||
* A web socket server that handles subscriptions.
|
||||
* A jobs processor that handles queued operations.
|
||||
|
||||
In the documentation so far, we've discussed how to deploy all of these
|
||||
functionalities bundled into a single monolith application. This is convenient
|
||||
as there is minimal configuration and horizontal scaling is as easy as upping
|
||||
the number of servers behind a single load balancer.
|
||||
|
||||
## Separating Talk into Microservices
|
||||
|
||||
Talk can be run in two or more separate clusters of servers by
|
||||
enabling/disabling different bits of functionality: webserver, socket server and
|
||||
jobs server.
|
||||
|
||||
Each microservice would deploy with the same codebase and configuration.
|
||||
|
||||
Note that the `cli serve` command, which is responsible for starting the server,
|
||||
contains flags that control whether `jobs` and `websockets` are enabled.
|
||||
|
||||
```
|
||||
talk :) ]$ bin/cli serve --help
|
||||
|
||||
Options:
|
||||
|
||||
...
|
||||
-j, --jobs enable job processing on this thread
|
||||
-w, --websockets enable the websocket (subscriptions) handler on this thread
|
||||
...
|
||||
```
|
||||
|
||||
Each Talk Microservice cluster can be deployed in an identical manner described
|
||||
in the other docs in this section with the omission of the `-j` and/or `-w`
|
||||
flags.
|
||||
|
||||
With routing logic in front of the webserver cluster, separation between public
|
||||
and protected assets can be achieved.
|
||||
|
||||
## When should I consider separating?
|
||||
|
||||
Consider a microservice deployment if:
|
||||
|
||||
* you are running plugins that require intensive job processing
|
||||
* you do not want to simplicity of single cluster horizontal scaling and want to
|
||||
tune the economy and performance of your install.
|
||||
* You run into scaling issues serving websockets
|
||||
|
||||
At scale, combining separate concerns in a single process makes it very
|
||||
difficult to understand what is taking up resources. With microservices, each
|
||||
server could be configured to sit behind it's own load balance and scale
|
||||
independently. Each variety of process can always have just enough resources.
|
||||
|
||||
An install that heavily utilizes the jobs queue could see delays in http service
|
||||
because of heavy jobs processes and/or delays in the execution of jobs processes
|
||||
due to increased server load as a result of Node's single thread infrustructure.
|
||||
|
||||
## Deployment Methodologies
|
||||
|
||||
Note that there is no flag to separate the http routes on the webserver.
|
||||
Separating the http server functionalities can be accomplished by the routing of
|
||||
various routes to the correct http server via an external upstream proxy like
|
||||
Google Cloud's Load Balancer, AWS's ELB, or a websever like NGINX/Apache. This
|
||||
can ensure that sensitive areas, such as the `/admin/` route are not available
|
||||
outside the firewall.
|
||||
|
||||
Talk's job processors are synchronized by Redis, so as long as all Talk
|
||||
instances access the same Redis cluster no additional configuration is needed
|
||||
when launching an independent jobs cluster.
|
||||
|
||||
If there are any features of Talk that you believe should be disable-able via
|
||||
server flags, please let us know and consider contributing it to the project!
|
||||
|
||||
## Deployment Flows/Scripts
|
||||
|
||||
We do not currently support any microservice based deployment flows. If you
|
||||
develop one yourself that is completely based on open source tooling, please
|
||||
consider contributing it to the project!
|
||||
@@ -0,0 +1,22 @@
|
||||
---
|
||||
title: Installation Troubleshooting
|
||||
keywords: install
|
||||
sidebar: talk_sidebar
|
||||
permalink: install-troubleshooting.html
|
||||
summary:
|
||||
---
|
||||
|
||||
This page tracks common issues that arise when installing Talk and provides resolutions.
|
||||
|
||||
## The Talk server seems to be working but the stream isn't showing up on my page.
|
||||
|
||||
Talk employs a _domain whitelist_ that controls which sites can contain comment threads. This prevents malicious folks from using your server to embed streams on unwanted pages.
|
||||
|
||||
If your comment thread isn't showing:
|
||||
|
||||
1. Log into your admin panel
|
||||
1. Go to the Configure tab
|
||||
1. Select the Tech Settings submenu
|
||||
1. Ensure that your Domain is the Permitted Domains list
|
||||
|
||||
Note: if your site has multiple subdomains, listing the domain itself (ie: `mydomain.com`) will enable Talk on all subdomains. If you would like to restrict Talk to certain subdomains, you must list all of them here (ie: `thisone.mydomain.com thatone.mydomain.com`).
|
||||
@@ -215,7 +215,7 @@ It is important to realize that when you're writing a Talk plugin you are writin
|
||||
|
||||
### Publish to npm
|
||||
|
||||
In order to [register](http://localhost:4000/plugins.html#plugin-registration) your _published_ plugin, you will need to [publish it to npm](https://docs.npmjs.com/getting-started/publishing-npm-packages).
|
||||
In order to [register](plugins.html#plugin-registration) your _published_ plugin, you will need to [publish it to npm](https://docs.npmjs.com/getting-started/publishing-npm-packages).
|
||||
|
||||
Once the package is published, update `plugins.json` to use the published plugin:
|
||||
|
||||
|
||||
@@ -50,13 +50,20 @@ const getCountsByAssetID = (context, asset_ids) => {
|
||||
* @param {Array<String>} id The ID of the asset
|
||||
* @param {Array<String>} excludeIgnored Exclude comments ignored by the requesting user
|
||||
*/
|
||||
const getCountsByAssetIDPersonalized = async (context, {assetId, excludeIgnored}) => {
|
||||
const getCountsByAssetIDPersonalized = async (context, {assetId, excludeIgnored, tags}) => {
|
||||
const query = {
|
||||
asset_id: assetId,
|
||||
status: {
|
||||
$in: ['NONE', 'ACCEPTED'],
|
||||
},
|
||||
};
|
||||
|
||||
if (tags) {
|
||||
query['tags.tag.name'] = {
|
||||
$in: tags,
|
||||
};
|
||||
}
|
||||
|
||||
const user = context.user;
|
||||
if (excludeIgnored && user) {
|
||||
|
||||
@@ -108,7 +115,7 @@ const getParentCountsByAssetID = (context, asset_ids) => {
|
||||
* @param {Array<String>} id The ID of the asset
|
||||
* @param {Array<String>} excludeIgnored Exclude comments ignored by the requesting user
|
||||
*/
|
||||
const getParentCountByAssetIDPersonalized = async (context, {assetId, excludeIgnored}) => {
|
||||
const getParentCountByAssetIDPersonalized = async (context, {assetId, excludeIgnored, tags}) => {
|
||||
const query = {
|
||||
asset_id: assetId,
|
||||
parent_id: null,
|
||||
@@ -116,6 +123,13 @@ const getParentCountByAssetIDPersonalized = async (context, {assetId, excludeIgn
|
||||
$in: ['NONE', 'ACCEPTED'],
|
||||
},
|
||||
};
|
||||
|
||||
if (tags) {
|
||||
query['tags.tag.name'] = {
|
||||
$in: tags,
|
||||
};
|
||||
}
|
||||
|
||||
const user = context.user;
|
||||
if (excludeIgnored && user) {
|
||||
|
||||
@@ -195,7 +209,7 @@ const getCountByParentIDPersonalized = async (context, {id, excludeIgnored}) =>
|
||||
* @return {Promise} resolves to the counts of the comments from the
|
||||
* query
|
||||
*/
|
||||
const getCommentCountByQuery = (context, {ids, statuses, asset_id, parent_id, author_id}) => {
|
||||
const getCommentCountByQuery = (context, {ids, statuses, asset_id, parent_id, author_id, tags}) => {
|
||||
let query = CommentModel.find();
|
||||
|
||||
if (ids) {
|
||||
@@ -218,6 +232,14 @@ const getCommentCountByQuery = (context, {ids, statuses, asset_id, parent_id, au
|
||||
query = query.where({author_id});
|
||||
}
|
||||
|
||||
if (tags) {
|
||||
query = query.find({
|
||||
'tags.tag.name': {
|
||||
$in: tags,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return CommentModel
|
||||
.find(query)
|
||||
.count();
|
||||
@@ -229,7 +251,7 @@ const getCommentCountByQuery = (context, {ids, statuses, asset_id, parent_id, au
|
||||
* @param {Object} context graph context
|
||||
* @param {Object} query query terms to apply to the comments query
|
||||
*/
|
||||
const getCommentsByQuery = async ({user}, {ids, statuses, asset_id, parent_id, author_id, limit, cursor, sort, excludeIgnored}) => {
|
||||
const getCommentsByQuery = async ({user}, {ids, statuses, asset_id, parent_id, author_id, limit, cursor, sort, excludeIgnored, tags}) => {
|
||||
let comments = CommentModel.find();
|
||||
|
||||
// Only administrators can search for comments with statuses that are not
|
||||
@@ -256,6 +278,14 @@ const getCommentsByQuery = async ({user}, {ids, statuses, asset_id, parent_id, a
|
||||
});
|
||||
}
|
||||
|
||||
if (tags) {
|
||||
comments = comments.find({
|
||||
'tags.tag.name': {
|
||||
$in: tags,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Only let an admin request any user or the current user request themself.
|
||||
if (user && (user.can(SEARCH_OTHERS_COMMENTS) || user.id === author_id) && author_id != null) {
|
||||
comments = comments.where({author_id});
|
||||
|
||||
@@ -15,6 +15,10 @@ const {
|
||||
EDIT_COMMENT
|
||||
} = require('../../perms/constants');
|
||||
|
||||
const {
|
||||
DISABLE_AUTOFLAG_SUSPECT_WORDS
|
||||
} = require('../../config');
|
||||
|
||||
const debug = require('debug')('talk:graph:mutators:tags');
|
||||
const plugins = require('../../services/plugins');
|
||||
|
||||
@@ -297,7 +301,10 @@ const createPublicComment = async (context, commentInput) => {
|
||||
// Otherwise just return the new comment.
|
||||
|
||||
// TODO: Check why the wordlist is undefined
|
||||
if (wordlist != null && wordlist.suspect != null) {
|
||||
|
||||
// If the wordlist has matched the suspect word filter and we haven't disabled
|
||||
// auto-flagging suspect words, then we should flag the comment!
|
||||
if (wordlist != null && wordlist.suspect != null && !DISABLE_AUTOFLAG_SUSPECT_WORDS) {
|
||||
|
||||
// TODO: this is kind of fragile, we should refactor this to resolve
|
||||
// all these const's that we're using like 'COMMENTS', 'FLAG' to be
|
||||
|
||||
@@ -4,31 +4,32 @@ const Asset = {
|
||||
recentComments({id}, _, {loaders: {Comments}}) {
|
||||
return Comments.genRecentComments.load(id);
|
||||
},
|
||||
comments({id}, {sort, limit, excludeIgnored}, {loaders: {Comments}}) {
|
||||
comments({id}, {sort, limit, deep, excludeIgnored, tags}, {loaders: {Comments}}) {
|
||||
return Comments.getByQuery({
|
||||
asset_id: id,
|
||||
sort,
|
||||
limit,
|
||||
parent_id: null,
|
||||
parent_id: deep ? undefined : null,
|
||||
tags,
|
||||
excludeIgnored,
|
||||
});
|
||||
},
|
||||
commentCount({id, commentCount}, {excludeIgnored}, {user, loaders: {Comments}}) {
|
||||
|
||||
commentCount({id, commentCount}, {excludeIgnored, tags}, {user, loaders: {Comments}}) {
|
||||
|
||||
// TODO: remove
|
||||
if (user && excludeIgnored) {
|
||||
return Comments.parentCountByAssetIDPersonalized({assetId: id, excludeIgnored});
|
||||
if ((user && excludeIgnored) || tags) {
|
||||
return Comments.parentCountByAssetIDPersonalized({assetId: id, excludeIgnored, tags});
|
||||
}
|
||||
if (commentCount != null) {
|
||||
return commentCount;
|
||||
}
|
||||
return Comments.parentCountByAssetID.load(id);
|
||||
},
|
||||
totalCommentCount({id, totalCommentCount}, {excludeIgnored}, {user, loaders: {Comments}}) {
|
||||
totalCommentCount({id, totalCommentCount}, {excludeIgnored, tags}, {user, loaders: {Comments}}) {
|
||||
|
||||
// TODO: remove
|
||||
if (user && excludeIgnored) {
|
||||
return Comments.countByAssetIDPersonalized({assetId: id, excludeIgnored});
|
||||
if ((user && excludeIgnored) || tags) {
|
||||
return Comments.countByAssetIDPersonalized({assetId: id, excludeIgnored, tags});
|
||||
}
|
||||
if (totalCommentCount != null) {
|
||||
return totalCommentCount;
|
||||
|
||||
+16
-8
@@ -243,12 +243,12 @@ input CommentsQuery {
|
||||
# Skip results from the last created_at timestamp.
|
||||
cursor: Date
|
||||
|
||||
# Filter by a specific tag name.
|
||||
tag: [String]
|
||||
|
||||
# Sort the results by created_at.
|
||||
sort: SORT_ORDER = REVERSE_CHRONOLOGICAL
|
||||
|
||||
# Filter by a specific tag name.
|
||||
tags: [String!]
|
||||
|
||||
# Exclude comments ignored by the requesting user
|
||||
excludeIgnored: Boolean
|
||||
}
|
||||
@@ -278,7 +278,7 @@ input CommentCountQuery {
|
||||
author_id: ID
|
||||
|
||||
# Filter by a specific tag name.
|
||||
tag: [String]
|
||||
tags: [String!]
|
||||
}
|
||||
|
||||
type EditInfo {
|
||||
@@ -569,14 +569,22 @@ type Asset {
|
||||
# Returns recent comments
|
||||
recentComments: [Comment!]
|
||||
|
||||
# The top level comments that are attached to the asset.
|
||||
comments(sort: SORT_ORDER = REVERSE_CHRONOLOGICAL, limit: Int = 10, excludeIgnored: Boolean): CommentConnection!
|
||||
# The comments that are attached to the asset.
|
||||
# If `deep` is true, it will return comments of all depths,
|
||||
# otherwise only top-level comments are returned.
|
||||
comments(
|
||||
sort: SORT_ORDER = REVERSE_CHRONOLOGICAL,
|
||||
limit: Int = 10,
|
||||
excludeIgnored: Boolean,
|
||||
tags: [String!]
|
||||
deep: Boolean,
|
||||
): CommentConnection!
|
||||
|
||||
# The count of top level comments on the asset.
|
||||
commentCount(excludeIgnored: Boolean): Int
|
||||
commentCount(excludeIgnored: Boolean, tags: [String!]): Int
|
||||
|
||||
# The total count of all comments made on the asset.
|
||||
totalCommentCount(excludeIgnored: Boolean): Int
|
||||
totalCommentCount(excludeIgnored: Boolean, tags: [String!]): Int
|
||||
|
||||
# The settings (rectified with the global settings) that should be applied to
|
||||
# this asset.
|
||||
|
||||
+1
-3
@@ -2,6 +2,7 @@ en:
|
||||
your_account_has_been_suspended: Your account has been temporarily suspended.
|
||||
your_account_has_been_banned: Your account has been banned.
|
||||
your_username_has_been_rejected: Your account has been suspended because your username has been deemed inappropriate. To restore your account please enter a new username.
|
||||
embed_comments_tab: Comments
|
||||
bandialog:
|
||||
are_you_sure: "Are you sure you would like to ban {0}?"
|
||||
ban_user: "Ban User?"
|
||||
@@ -31,7 +32,6 @@ en:
|
||||
comment_post_notif_premod: "Thank you for posting. Our moderation team will review your comment shortly."
|
||||
comment_post_banned_word: "Your comment contains one or more words that are not permitted, so it will not be published. If you think this message is incorrect, please contact our moderation team."
|
||||
characters_remaining: "characters remaining"
|
||||
comment_is_best: "This comment is one of the best"
|
||||
comment_offensive: "This comment is offensive"
|
||||
comment_singular: Comment
|
||||
comment_plural: Comments
|
||||
@@ -305,7 +305,6 @@ en:
|
||||
report_notif: "Thank you for reporting this comment. Our moderation team has been notified and will review it shortly."
|
||||
report_notif_remove: "Your report has been removed."
|
||||
reported: Reported
|
||||
set_best: "Tag as Best"
|
||||
settings:
|
||||
all_comments: "All Comments"
|
||||
from_settings_page: "From the Profile Page you can see your comment history."
|
||||
@@ -362,7 +361,6 @@ en:
|
||||
write_message: "Write a message"
|
||||
send: Send
|
||||
thank_you: "We value your safety and feedback. A moderator will review your report."
|
||||
unset_best: "Untag as Best"
|
||||
user:
|
||||
bio_flags: "flags for this bio"
|
||||
user_bio: "User Bio"
|
||||
|
||||
+1
-3
@@ -2,6 +2,7 @@ es:
|
||||
your_account_has_been_suspended: Su cuenta ha sido temporalmente suspendida.
|
||||
your_account_has_been_banned: Su cuenta ha sido suspendida.
|
||||
your_username_has_been_rejected: Su cuenta ha sido suspendida porque tu nombre de usuario ha sido considerado no apropiado para el espacio. Para recuperar la cuenta, por favor ingresar un nuevo nombre de usuario.
|
||||
embed_comments_tab: Comentarios
|
||||
bandialog:
|
||||
are_you_sure: "¿Estás segura que quieres suspender a {0}?"
|
||||
ban_user: "¿Quieres suspender el Usuario?"
|
||||
@@ -31,7 +32,6 @@ es:
|
||||
comment_post_notif_premod: "Gracias por el comentario. Nuestro equipo de moderación va a revisarlo muy pronto."
|
||||
comment_post_banned_word: "Tu comentario contiene una o más palabras que no están permitidas en nuestro espacio, por lo que no será publicado. Si crees que es un error, por favor contacta a nuestro equipo de moderación."
|
||||
characters_remaining: "carácteres restantes"
|
||||
comment_is_best: "Este comentario es uno de los mejores"
|
||||
comment_offensive: "Este comentario es ofensivo"
|
||||
comment_singular: Comentario
|
||||
comment_plural: Comentarios
|
||||
@@ -297,7 +297,6 @@ es:
|
||||
report_notif: "Gracias por reportar este comentario. Nuestro equipo de moderación ha sido notificado y muy pronto lo va a revisar."
|
||||
report_notif_remove: "Tu reporte ha sido eliminado."
|
||||
reported: "Reportado"
|
||||
set_best: "Etiquetar como el mejor"
|
||||
settings:
|
||||
all_comments: "Todos los comentarios"
|
||||
from_settings_page: "Desde la página de configuración puedes ver tu historial de comentarios."
|
||||
@@ -385,7 +384,6 @@ es:
|
||||
write_message: "Escribir un mensaje"
|
||||
send: "Enviar"
|
||||
thank_you: "Valoramos tanto su seguridad en este espacio como sus comentarios. Un o una moderadora va a leer su reporte."
|
||||
unset_best: "Des-etiquetar como el mejor"
|
||||
user:
|
||||
bio_flags: "reportes para este bio"
|
||||
user_bio: "Bio de Usuario"
|
||||
|
||||
+6
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "talk",
|
||||
"version": "2.3.0",
|
||||
"version": "2.5.0",
|
||||
"description": "A better commenting experience from Mozilla, The New York Times, and the Washington Post. https://coralproject.net",
|
||||
"main": "app.js",
|
||||
"scripts": {
|
||||
@@ -77,7 +77,9 @@
|
||||
"env-rewrite": "^1.0.2",
|
||||
"express": "^4.15.2",
|
||||
"express-session": "^1.15.1",
|
||||
"file-loader": "^0.11.2",
|
||||
"form-data": "^2.1.2",
|
||||
"fs-extra": "^3.0.1",
|
||||
"gql-merge": "^0.0.4",
|
||||
"graphql": "^0.9.1",
|
||||
"graphql-errors": "^2.1.0",
|
||||
@@ -109,6 +111,7 @@
|
||||
"passport-jwt": "^2.2.1",
|
||||
"passport-local": "^1.0.0",
|
||||
"prop-types": "^15.5.10",
|
||||
"query-strings": "^0.0.1",
|
||||
"react-apollo": "^1.1.0",
|
||||
"react-input-autosize": "^1.1.4",
|
||||
"react-recaptcha": "^2.2.6",
|
||||
@@ -208,9 +211,10 @@
|
||||
"style-loader": "^0.16.0",
|
||||
"supertest": "^2.0.1",
|
||||
"timeago.js": "^2.0.3",
|
||||
"url-loader": "^0.5.9",
|
||||
"webpack": "^2.3.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^7.8.0"
|
||||
"node": "^7.10.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export {addNotification} from 'coral-framework/actions/notification';
|
||||
@@ -1 +1,5 @@
|
||||
export {default as withReaction} from './withReaction';
|
||||
export {default as withTags} from './withTags';
|
||||
export {default as withFragments} from 'coral-framework/hocs/withFragments';
|
||||
export {default as excludeIf} from 'coral-framework/hocs/excludeIf';
|
||||
export {default as connect} from 'coral-framework/hocs/connect';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import get from 'lodash/get';
|
||||
import uuid from 'uuid/v4';
|
||||
import {connect} from 'react-redux';
|
||||
import {connect} from 'plugin-api/beta/client/hocs';
|
||||
import {bindActionCreators} from 'redux';
|
||||
import {getDisplayName} from 'coral-framework/helpers/hoc';
|
||||
import {compose, gql} from 'react-apollo';
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import React from 'react';
|
||||
import {connect} from 'react-redux';
|
||||
import {bindActionCreators} from 'redux';
|
||||
import {compose, gql} from 'react-apollo';
|
||||
import {getDisplayName} from 'coral-framework/helpers/hoc';
|
||||
import {capitalize} from 'coral-framework/helpers/strings';
|
||||
import {withAddTag, withRemoveTag} from 'coral-framework/graphql/mutations';
|
||||
import withFragments from 'coral-framework/hocs/withFragments';
|
||||
import {addNotification} from 'coral-framework/actions/notification';
|
||||
import {forEachError, isTagged} from 'coral-framework/utils';
|
||||
|
||||
export default (tag) => (WrappedComponent) => {
|
||||
if (typeof tag !== 'string') {
|
||||
console.error('Tag must be a valid string');
|
||||
return null;
|
||||
}
|
||||
|
||||
const Tag = capitalize(tag);
|
||||
const TAG = tag.toUpperCase();
|
||||
|
||||
class WithTags extends React.Component {
|
||||
loading = false;
|
||||
|
||||
postTag = () => {
|
||||
const {comment, asset, addNotification} = this.props;
|
||||
|
||||
if (this.loading) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.loading = true;
|
||||
|
||||
this.props.addTag({
|
||||
id: comment.id,
|
||||
name: TAG,
|
||||
assetId: asset.id,
|
||||
itemType: 'COMMENTS',
|
||||
})
|
||||
.then(() => {
|
||||
this.loading = false;
|
||||
})
|
||||
.catch((err) => {
|
||||
this.loading = false;
|
||||
forEachError(err, ({msg}) => addNotification('error', msg));
|
||||
});
|
||||
}
|
||||
|
||||
deleteTag = () => {
|
||||
const {comment, asset, addNotification} = this.props;
|
||||
|
||||
if (this.loading) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.props.removeTag({
|
||||
id: comment.id,
|
||||
name: TAG,
|
||||
assetId: asset.id,
|
||||
itemType: 'COMMENTS',
|
||||
})
|
||||
.then(() => {
|
||||
this.loading = false;
|
||||
})
|
||||
.catch((err) => {
|
||||
this.loading = false;
|
||||
forEachError(err, ({msg}) => addNotification('error', msg));
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const {comment} = this.props;
|
||||
|
||||
const alreadyTagged = isTagged(comment.tags, TAG);
|
||||
|
||||
return <WrappedComponent
|
||||
user={this.props.user}
|
||||
comment={comment}
|
||||
alreadyTagged={alreadyTagged}
|
||||
postTag={this.postTag}
|
||||
deleteTag={this.deleteTag}
|
||||
/>;
|
||||
}
|
||||
}
|
||||
|
||||
const mapStateToProps = (state) => ({
|
||||
user: state.auth.toJS().user,
|
||||
});
|
||||
|
||||
const mapDispatchToProps = (dispatch) =>
|
||||
bindActionCreators({addNotification}, dispatch);
|
||||
|
||||
const enhance = compose(
|
||||
withFragments({
|
||||
comment: gql`
|
||||
fragment ${Tag}Button_comment on Comment {
|
||||
tags {
|
||||
tag {
|
||||
name
|
||||
}
|
||||
}
|
||||
}`
|
||||
}),
|
||||
connect(mapStateToProps, mapDispatchToProps),
|
||||
withAddTag,
|
||||
withRemoveTag
|
||||
);
|
||||
|
||||
WithTags.displayName = `WithTags(${getDisplayName(WrappedComponent)})`;
|
||||
|
||||
return enhance(WithTags);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export const pluginConfigSelector = (state) => state.config.pluginConfig;
|
||||
@@ -1,3 +1,9 @@
|
||||
export {t} from 'coral-framework/services/i18n';
|
||||
export {t, timeago} from 'coral-framework/services/i18n';
|
||||
export {can} from 'coral-framework/services/perms';
|
||||
export {isSlotEmpty} from 'coral-framework/helpers/plugins';
|
||||
import {isSlotEmpty as ise} from 'coral-framework/helpers/plugins';
|
||||
|
||||
// @TODO: Deprecated.
|
||||
export function isSlotEmpty(...args) {
|
||||
console.warn('A plugin is using `isSlotEmpty` which has been deprecated, please port to the new API using the `IfSlotIsEmpty` and `IfSlotIsNotEmpty` components.');
|
||||
return ise(...args);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
export {
|
||||
isTagged,
|
||||
insertCommentsSorted,
|
||||
getSlotFragmentSpreads,
|
||||
forEachError,
|
||||
getDefinitionName,
|
||||
} from 'coral-framework/utils';
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user