mirror of
https://github.com/wassname/talk.git
synced 2026-07-09 09:20:38 +08:00
[CORL-181] Comment Count Injection (#2581)
* feat: inject comment counts * fix: tests * feat: integrate stream embed with coral counts * chore: refactor constants * test: test for count bundle * test: test live comment count integration with stream embed * feat: use defer * fix: snapshot * feat: auto add count.js in when calling Coral.createStreamEmbed * fix: tests * fix: rm duplicate test * chore: remove unuse file
This commit is contained in:
@@ -29,6 +29,7 @@ module.exports = {
|
||||
"^coral-account/(.*)$": "<rootDir>/src/core/client/account/$1",
|
||||
"^coral-admin/(.*)$": "<rootDir>/src/core/client/admin/$1",
|
||||
"^coral-auth/(.*)$": "<rootDir>/src/core/client/auth/$1",
|
||||
"^coral-count/(.*)$": "<rootDir>/src/core/client/count/$1",
|
||||
"^coral-ui/(.*)$": "<rootDir>/src/core/client/ui/$1",
|
||||
"^coral-stream/(.*)$": "<rootDir>/src/core/client/stream/$1",
|
||||
"^coral-framework/(.*)$": "<rootDir>/src/core/client/framework/$1",
|
||||
|
||||
@@ -752,5 +752,44 @@ export default function createWebpackConfig(
|
||||
),
|
||||
]),
|
||||
},
|
||||
/* Webpack config for count */
|
||||
{
|
||||
...baseConfig,
|
||||
optimization: {
|
||||
...baseConfig.optimization,
|
||||
// Ensure that we never split the count into chunks.
|
||||
splitChunks: {
|
||||
chunks: "async",
|
||||
},
|
||||
// We can turn on sideEffects here as we don't use
|
||||
// css here and don't run into: https://github.com/webpack/webpack/issues/7094
|
||||
sideEffects: true,
|
||||
},
|
||||
entry: [paths.appCountIndex],
|
||||
output: {
|
||||
...baseConfig.output,
|
||||
// don't hash the count, cache-busting must be completed by the requester
|
||||
// as this lives in a static template on the embed site.
|
||||
filename: "assets/js/count.js",
|
||||
},
|
||||
plugins: filterPlugins([
|
||||
...baseConfig.plugins!,
|
||||
...ifWatch(
|
||||
// Generates an `embed.html` file with the <script> injected.
|
||||
new HtmlWebpackPlugin({
|
||||
filename: "count.html",
|
||||
template: paths.appCountHTML,
|
||||
inject: "body",
|
||||
})
|
||||
),
|
||||
...ifBuild(
|
||||
new WebpackAssetsManifest({
|
||||
output: "count-asset-manifest.json",
|
||||
entrypoints: true,
|
||||
integrity: true,
|
||||
})
|
||||
),
|
||||
]),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -34,6 +34,9 @@ export default {
|
||||
appAuthCallbackHTML: resolveSrc("core/client/auth-callback/index.html"),
|
||||
appAuthCallbackIndex: resolveSrc("core/client/auth-callback/index.ts"),
|
||||
|
||||
appCountHTML: resolveSrc("core/client/count/index.html"),
|
||||
appCountIndex: resolveSrc("core/client/count/index.ts"),
|
||||
|
||||
appInstallHTML: resolveSrc("core/client/install/index.html"),
|
||||
appInstallLocalesTemplate: resolveSrc("core/client/install/locales.ts"),
|
||||
appInstallIndex: resolveSrc("core/client/install/index.tsx"),
|
||||
|
||||
@@ -2,9 +2,9 @@ import { stripIndent } from "common-tags";
|
||||
import { Localized } from "fluent-react/compat";
|
||||
import React, { FunctionComponent, useMemo } from "react";
|
||||
|
||||
import { getLocationOrigin } from "coral-common/utils";
|
||||
import { CopyButton } from "coral-framework/components";
|
||||
import { GetMessage, withGetMessage } from "coral-framework/lib/i18n";
|
||||
import { getLocationOrigin } from "coral-framework/utils";
|
||||
import { HorizontalGutter, Typography } from "coral-ui/components";
|
||||
|
||||
import Header from "../../Header";
|
||||
@@ -56,6 +56,8 @@ const EmbedCode: FunctionComponent<Props> = ({ staticURI, getMessage }) => {
|
||||
(function() {
|
||||
var d = document, s = d.createElement('script');
|
||||
s.src = '${script}/assets/js/embed.js';
|
||||
s.async = false;
|
||||
s.defer = true;
|
||||
s.onload = function() {
|
||||
Coral.createStreamEmbed({
|
||||
id: "coral_thread",
|
||||
|
||||
@@ -138,12 +138,14 @@ each of your site’s stories.
|
||||
<textarea
|
||||
className="EmbedCode-textArea"
|
||||
readOnly={true}
|
||||
rows={22}
|
||||
rows={24}
|
||||
value="<div id=\\"coral_thread\\"></div>
|
||||
<script type=\\"text/javascript\\">
|
||||
(function() {
|
||||
var d = document, s = d.createElement('script');
|
||||
s.src = 'http://localhost/assets/js/embed.js';
|
||||
s.async = false;
|
||||
s.defer = true;
|
||||
s.onload = function() {
|
||||
Coral.createStreamEmbed({
|
||||
id: \\"coral_thread\\",
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* getCurrentScriptOrigin will try to find the script origin.
|
||||
* For legacy browsers a fallbackIdentifier is required.
|
||||
*
|
||||
* @argument fallbackID id attached to a script tag to get its origin from for legacy browsrs.
|
||||
*/
|
||||
function getCurrentScriptOrigin(fallbackID?: string) {
|
||||
// Find current script (modern browsers).
|
||||
let script = document.currentScript as HTMLScriptElement | null;
|
||||
|
||||
if (!script && fallbackID) {
|
||||
// Find script tag with `fallbackIdentifier` as its id.
|
||||
script = document.getElementById(fallbackID) as HTMLScriptElement | null;
|
||||
if (!script) {
|
||||
// Find script tag with `fallbackIdentifier` as its className.
|
||||
script = document.querySelector(
|
||||
`.${fallbackID}`
|
||||
) as HTMLScriptElement | null;
|
||||
}
|
||||
}
|
||||
if (!script) {
|
||||
throw new Error("Current script not found");
|
||||
}
|
||||
return new URL(script.src).origin;
|
||||
}
|
||||
|
||||
export default getCurrentScriptOrigin;
|
||||
@@ -0,0 +1,23 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Coral - Count</title>
|
||||
<meta charset="utf-8" />
|
||||
<meta http-equiv="Content-type" content="text/html; charset=utf-8" />
|
||||
<meta name="viewport" content="width=device-width, user-scalable=no" />
|
||||
<link rel="canonical" href="http://localhost:8080/" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<h1>Comment Counts</h1>
|
||||
<h3>Specified by canonical link</h3>
|
||||
<p><a href="http://localhost:8080/">Default: <span class="coral-count"></span></a></p>
|
||||
|
||||
<h3>Specified by data-coral-url</h3>
|
||||
<p><a href="http://localhost:8080/story.html">Story: <span class="coral-count" data-coral-url="http://localhost:8080/story.html"></span></a></p>
|
||||
<p><a href="http://localhost:8080/storyButton.html">Story With Button: <span class="coral-count" data-coral-url="http://localhost:8080/storyButton.html"></span></a></p>
|
||||
|
||||
<h3>No text</h3>
|
||||
<p><a href="http://localhost:8080/">Default notext: <span class="coral-count" data-coral-notext="true" data-coral-url="http://localhost:8080/"></span></a></p>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,61 @@
|
||||
import { COUNT_SELECTOR, ORIGIN_FALLBACK_ID } from "coral-framework/constants";
|
||||
import detectCountScript from "coral-framework/helpers/detectCountScript";
|
||||
import resolveStoryURL from "coral-framework/helpers/resolveStoryURL";
|
||||
import jsonp from "coral-framework/utils/jsonp";
|
||||
|
||||
import getCurrentScriptOrigin from "./getCurrentScriptOrigin";
|
||||
import injectJSONPCallback from "./injectJSONPCallback";
|
||||
|
||||
/** Arguments that will be send to the server. */
|
||||
interface CountQueryArgs {
|
||||
id?: string;
|
||||
url?: string;
|
||||
notext?: boolean;
|
||||
}
|
||||
|
||||
/** createCountQueryRef creates a unique reference from the query args */
|
||||
function createCountQueryRef(args: CountQueryArgs) {
|
||||
return btoa(`${JSON.stringify(!!args.notext)};${args.id || args.url}`);
|
||||
}
|
||||
|
||||
/** Detects count elements and use jsonp to inject the counts. */
|
||||
function detectAndInject() {
|
||||
const ORIGIN = getCurrentScriptOrigin(ORIGIN_FALLBACK_ID);
|
||||
const STORY_URL = resolveStoryURL();
|
||||
/** A map of references pointing to the count query arguments */
|
||||
const queryMap: Record<string, CountQueryArgs> = {};
|
||||
|
||||
// Find all the selected elements and fill the queryMap.
|
||||
const elements = document.querySelectorAll(COUNT_SELECTOR);
|
||||
Array.prototype.forEach.call(elements, (element: HTMLElement) => {
|
||||
let url = element.dataset.coralUrl;
|
||||
const id = element.dataset.coralId;
|
||||
const notext = element.dataset.coralNotext === "true";
|
||||
if (!url && !id) {
|
||||
url = STORY_URL;
|
||||
element.dataset.coralUrl = STORY_URL;
|
||||
}
|
||||
const args = { id, url, notext };
|
||||
const ref = createCountQueryRef(args);
|
||||
if (!(ref in queryMap)) {
|
||||
queryMap[ref] = args;
|
||||
}
|
||||
element.dataset.coralRef = ref;
|
||||
});
|
||||
|
||||
// Call server using JSONP.
|
||||
Object.keys(queryMap).forEach(ref => {
|
||||
const { url, id, notext } = queryMap[ref];
|
||||
const args = { url, id, notext: notext ? "true" : "false", ref };
|
||||
jsonp(`${ORIGIN}/api/story/count.js`, "CoralCount.setCount", args);
|
||||
});
|
||||
}
|
||||
|
||||
export function main() {
|
||||
injectJSONPCallback();
|
||||
detectAndInject();
|
||||
}
|
||||
|
||||
if (!detectCountScript() && process.env.NODE_ENV !== "test") {
|
||||
main();
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { COUNT_SELECTOR } from "coral-framework/constants";
|
||||
|
||||
/** Injects a global CoralCount callback into the window object to be used in JSONP */
|
||||
function injectJSONPCallback() {
|
||||
(window as any).CoralCount = {
|
||||
setCount: (data: { ref: string; html: string }) => {
|
||||
// Find all the elements with ref.
|
||||
const elements = document.querySelectorAll(
|
||||
`${COUNT_SELECTOR}[data-coral-ref='${data.ref}']`
|
||||
);
|
||||
Array.prototype.forEach.call(elements, (element: HTMLElement) => {
|
||||
element.innerHTML = data.html;
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default injectJSONPCallback;
|
||||
@@ -0,0 +1,95 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Calls JSONP 1`] = `
|
||||
<body>
|
||||
<span
|
||||
class="coral-count"
|
||||
data-coral-ref="ZmFsc2U7aHR0cDovL2xvY2FsaG9zdDo4MDgwL3N0b3J5Lmh0bWw="
|
||||
data-coral-url="http://localhost:8080/story.html"
|
||||
/>
|
||||
<span
|
||||
class="coral-count"
|
||||
data-coral-id="1234-5678-91021"
|
||||
data-coral-ref="ZmFsc2U7MTIzNC01Njc4LTkxMDIx"
|
||||
/>
|
||||
<span
|
||||
class="coral-count"
|
||||
data-coral-ref="ZmFsc2U7aHR0cDovL2xvY2FsaG9zdDo4MDgwLw=="
|
||||
data-coral-url="http://localhost:8080/"
|
||||
data-notext="true"
|
||||
/>
|
||||
<span
|
||||
class="coral-count"
|
||||
data-coral-ref="ZmFsc2U7aHR0cDovL2xvY2FsaG9zdDo4MDgwLw=="
|
||||
data-coral-url="http://localhost:8080/"
|
||||
/>
|
||||
<script
|
||||
src="http://localhost:8080/api/story/count.js?callback=CoralCount.setCount&url=http%3A%2F%2Flocalhost%3A8080%2Fstory.html¬ext=false&ref=ZmFsc2U7aHR0cDovL2xvY2FsaG9zdDo4MDgwL3N0b3J5Lmh0bWw%3D"
|
||||
/>
|
||||
<script
|
||||
src="http://localhost:8080/api/story/count.js?callback=CoralCount.setCount&id=1234-5678-91021¬ext=false&ref=ZmFsc2U7MTIzNC01Njc4LTkxMDIx"
|
||||
/>
|
||||
<script
|
||||
src="http://localhost:8080/api/story/count.js?callback=CoralCount.setCount&url=http%3A%2F%2Flocalhost%3A8080%2F¬ext=false&ref=ZmFsc2U7aHR0cDovL2xvY2FsaG9zdDo4MDgwLw%3D%3D"
|
||||
/>
|
||||
</body>
|
||||
`;
|
||||
|
||||
exports[`Inject counts 1`] = `
|
||||
<body>
|
||||
<span
|
||||
class="coral-count"
|
||||
data-coral-ref="ZmFsc2U7aHR0cDovL2xvY2FsaG9zdDo4MDgwL3N0b3J5Lmh0bWw="
|
||||
data-coral-url="http://localhost:8080/story.html"
|
||||
/>
|
||||
<span
|
||||
class="coral-count"
|
||||
data-coral-id="1234-5678-91021"
|
||||
data-coral-ref="ZmFsc2U7MTIzNC01Njc4LTkxMDIx"
|
||||
/>
|
||||
<span
|
||||
class="coral-count"
|
||||
data-coral-ref="ZmFsc2U7aHR0cDovL2xvY2FsaG9zdDo4MDgwLw=="
|
||||
data-coral-url="http://localhost:8080/"
|
||||
data-notext="true"
|
||||
>
|
||||
<span
|
||||
class="coral-count-number"
|
||||
>
|
||||
5
|
||||
</span>
|
||||
|
||||
<span
|
||||
class="coral-count-text"
|
||||
>
|
||||
Comments
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
class="coral-count"
|
||||
data-coral-ref="ZmFsc2U7aHR0cDovL2xvY2FsaG9zdDo4MDgwLw=="
|
||||
data-coral-url="http://localhost:8080/"
|
||||
>
|
||||
<span
|
||||
class="coral-count-number"
|
||||
>
|
||||
5
|
||||
</span>
|
||||
|
||||
<span
|
||||
class="coral-count-text"
|
||||
>
|
||||
Comments
|
||||
</span>
|
||||
</span>
|
||||
<script
|
||||
src="http://localhost:8080/api/story/count.js?callback=CoralCount.setCount&url=http%3A%2F%2Flocalhost%3A8080%2Fstory.html¬ext=false&ref=ZmFsc2U7aHR0cDovL2xvY2FsaG9zdDo4MDgwL3N0b3J5Lmh0bWw%3D"
|
||||
/>
|
||||
<script
|
||||
src="http://localhost:8080/api/story/count.js?callback=CoralCount.setCount&id=1234-5678-91021¬ext=false&ref=ZmFsc2U7MTIzNC01Njc4LTkxMDIx"
|
||||
/>
|
||||
<script
|
||||
src="http://localhost:8080/api/story/count.js?callback=CoralCount.setCount&url=http%3A%2F%2Flocalhost%3A8080%2F¬ext=false&ref=ZmFsc2U7aHR0cDovL2xvY2FsaG9zdDo4MDgwLw%3D%3D"
|
||||
/>
|
||||
</body>
|
||||
`;
|
||||
@@ -0,0 +1,53 @@
|
||||
beforeAll(async () => {
|
||||
const script = document.createElement("script");
|
||||
script.src = "http://localhost:8080/assets/js/count.js";
|
||||
Object.defineProperty(window.document, "currentScript", {
|
||||
value: script,
|
||||
});
|
||||
|
||||
const link = document.createElement("link");
|
||||
link.rel = "canonical";
|
||||
link.href = "http://localhost:8080/";
|
||||
document.head.appendChild(link);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
document.body.innerHTML = "";
|
||||
const tags = [
|
||||
{
|
||||
coralUrl: "http://localhost:8080/story.html",
|
||||
},
|
||||
{
|
||||
coralId: "1234-5678-91021",
|
||||
},
|
||||
{
|
||||
notext: "true",
|
||||
},
|
||||
{},
|
||||
];
|
||||
|
||||
tags.forEach(attrs => {
|
||||
const element = document.createElement("span");
|
||||
element.className = "coral-count";
|
||||
Object.assign(element.dataset, attrs);
|
||||
document.body.appendChild(element);
|
||||
});
|
||||
(await import("../")).main();
|
||||
});
|
||||
|
||||
it("Sets the JSONP callback", async () => {
|
||||
expect((window as any).CoralCount).toBeDefined();
|
||||
});
|
||||
|
||||
it("Calls JSONP", async () => {
|
||||
expect(document.body).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("Inject counts", async () => {
|
||||
(window as any).CoralCount.setCount({
|
||||
ref: "ZmFsc2U7aHR0cDovL2xvY2FsaG9zdDo4MDgwLw==",
|
||||
html:
|
||||
'<span class="coral-count-number">5</span> <span class="coral-count-text">Comments</span>',
|
||||
});
|
||||
expect(document.body).toMatchSnapshot();
|
||||
});
|
||||
@@ -1,6 +1,8 @@
|
||||
import { EventEmitter2 } from "eventemitter2";
|
||||
|
||||
import { getLocationOrigin, parseQuery } from "coral-common/utils";
|
||||
import { parseQuery } from "coral-common/utils";
|
||||
import resolveStoryURL from "coral-framework/helpers/resolveStoryURL";
|
||||
import getLocationOrigin from "coral-framework/utils/getLocationOrigin";
|
||||
|
||||
import { default as create, StreamEmbed } from "./StreamEmbed";
|
||||
|
||||
@@ -15,22 +17,6 @@ export interface Config {
|
||||
accessToken?: string;
|
||||
}
|
||||
|
||||
function resolveStoryURL() {
|
||||
const canonical = document.querySelector(
|
||||
'link[rel="canonical"]'
|
||||
) as HTMLLinkElement;
|
||||
if (canonical) {
|
||||
return canonical.href;
|
||||
}
|
||||
|
||||
// tslint:disable-next-line:no-console
|
||||
console.warn(
|
||||
"This page does not include a canonical link tag. Coral has inferred this story_url from the window object. Query params have been stripped, which may cause a single thread to be present across multiple pages."
|
||||
);
|
||||
|
||||
return getLocationOrigin() + window.location.pathname;
|
||||
}
|
||||
|
||||
export function createStreamEmbed(config: Config): StreamEmbed {
|
||||
// Parse query params
|
||||
const query = parseQuery(location.search);
|
||||
|
||||
@@ -12,9 +12,11 @@ import {
|
||||
withConfig,
|
||||
withEventEmitter,
|
||||
withIOSSafariWidthWorkaround,
|
||||
withLiveCommentCount,
|
||||
withPymStorage,
|
||||
withSetCommentID,
|
||||
} from "./decorators";
|
||||
import injectCountScriptIfNeeded from "./injectCountScriptIfNeeded";
|
||||
import onIntersect, { OnIntersectCancellation } from "./onIntersect";
|
||||
import PymControl, {
|
||||
defaultPymControlFactory,
|
||||
@@ -46,6 +48,10 @@ export class StreamEmbed {
|
||||
) {
|
||||
this.config = config;
|
||||
this.pymControlFactory = pymControlFactory;
|
||||
|
||||
// Detect if comment count injection is needed and add the count script.
|
||||
injectCountScriptIfNeeded(config.rootURL);
|
||||
|
||||
if (config.commentID) {
|
||||
// Delay emit of `showPermalink` event to allow
|
||||
// user enough time to setup event listeners.
|
||||
@@ -132,6 +138,7 @@ export class StreamEmbed {
|
||||
withClickEvent,
|
||||
withSetCommentID,
|
||||
withEventEmitter(this.config.eventEmitter),
|
||||
withLiveCommentCount(this.config.eventEmitter),
|
||||
withPymStorage(localStorage, "localStorage"),
|
||||
withPymStorage(sessionStorage, "sessionStorage"),
|
||||
withConfig(externalConfig),
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`should live update counts when receiving a commentCount event 1`] = `
|
||||
<body>
|
||||
<span
|
||||
class="coral-count"
|
||||
data-coral-id="story-1"
|
||||
>
|
||||
<span
|
||||
class="coral-count-number"
|
||||
>
|
||||
2
|
||||
</span>
|
||||
|
||||
<span
|
||||
class="coral-count-text"
|
||||
>
|
||||
Comments
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
class="coral-count"
|
||||
data-coral-url="http://localhost/stories/story-1"
|
||||
>
|
||||
<span
|
||||
class="coral-count-number"
|
||||
>
|
||||
2
|
||||
</span>
|
||||
|
||||
<span
|
||||
class="coral-count-text"
|
||||
>
|
||||
Comments
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
class="coral-count"
|
||||
data-coral-id="story-x"
|
||||
>
|
||||
<span
|
||||
class="coral-count-number"
|
||||
>
|
||||
4
|
||||
</span>
|
||||
|
||||
<span
|
||||
class="coral-count-text"
|
||||
>
|
||||
Comments
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
class="x-count"
|
||||
data-coral-id="story-1"
|
||||
>
|
||||
<span
|
||||
class="coral-count-number"
|
||||
>
|
||||
1
|
||||
</span>
|
||||
|
||||
<span
|
||||
class="coral-count-text"
|
||||
>
|
||||
Comment
|
||||
</span>
|
||||
</span>
|
||||
</body>
|
||||
`;
|
||||
@@ -5,6 +5,7 @@ export { default as withSetCommentID } from "./withSetCommentID";
|
||||
export { default as withEventEmitter } from "./withEventEmitter";
|
||||
export { default as withPymStorage } from "./withPymStorage";
|
||||
export { default as withConfig } from "./withConfig";
|
||||
export { default as withLiveCommentCount } from "./withLiveCommentCount";
|
||||
export {
|
||||
default as withIOSSafariWidthWorkaround,
|
||||
} from "./withIOSSafariWidthWorkaround";
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { EventEmitter2 } from "eventemitter2";
|
||||
|
||||
import withLiveCommentCount from "./withLiveCommentCount";
|
||||
|
||||
beforeAll(() => {
|
||||
const elementWithID = document.createElement("span");
|
||||
elementWithID.className = "coral-count";
|
||||
elementWithID.dataset.coralId = "story-1";
|
||||
elementWithID.innerHTML =
|
||||
'<span class="coral-count-number">1</span> <span class="coral-count-text">Comment</span>';
|
||||
document.body.appendChild(elementWithID);
|
||||
|
||||
const elementWithURL = document.createElement("span");
|
||||
elementWithURL.className = "coral-count";
|
||||
elementWithURL.dataset.coralUrl = "http://localhost/stories/story-1";
|
||||
elementWithURL.innerHTML =
|
||||
'<span class="coral-count-number">1</span> <span class="coral-count-text">Comment</span>';
|
||||
document.body.appendChild(elementWithURL);
|
||||
|
||||
const nonMatchingElement = document.createElement("span");
|
||||
nonMatchingElement.className = "coral-count";
|
||||
nonMatchingElement.dataset.coralId = "story-x";
|
||||
nonMatchingElement.innerHTML =
|
||||
'<span class="coral-count-number">4</span> <span class="coral-count-text">Comments</span>';
|
||||
document.body.appendChild(nonMatchingElement);
|
||||
|
||||
const wrongSelectorElement = document.createElement("span");
|
||||
wrongSelectorElement.className = "x-count";
|
||||
wrongSelectorElement.dataset.coralId = "story-1";
|
||||
wrongSelectorElement.innerHTML =
|
||||
'<span class="coral-count-number">1</span> <span class="coral-count-text">Comment</span>';
|
||||
document.body.appendChild(wrongSelectorElement);
|
||||
});
|
||||
|
||||
it("should live update counts when receiving a commentCount event", () => {
|
||||
const events = new EventEmitter2({ wildcard: true });
|
||||
const fakePym = null as any;
|
||||
withLiveCommentCount(events)(fakePym);
|
||||
events.emit("commentCount", {
|
||||
number: 2,
|
||||
storyID: "story-1",
|
||||
storyURL: "http://localhost/stories/story-1",
|
||||
text: "Comments",
|
||||
});
|
||||
expect(document.body).toMatchSnapshot();
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { EventEmitter2 } from "eventemitter2";
|
||||
|
||||
import { COUNT_SELECTOR } from "coral-framework/constants";
|
||||
|
||||
import { Decorator } from "./types";
|
||||
|
||||
/**
|
||||
* withLiveCommentCount will listen to `commentCount` events
|
||||
* and update any comment counts managed by our `count.js` script.
|
||||
*/
|
||||
const withLiveCommentCount = (eventEmitter: EventEmitter2): Decorator => () => {
|
||||
eventEmitter.on("commentCount", args => {
|
||||
// Find all matching elements.
|
||||
const elements = document.querySelectorAll(
|
||||
`${COUNT_SELECTOR}[data-coral-url='${
|
||||
args.storyURL
|
||||
}'], ${COUNT_SELECTOR}[data-coral-id='${args.storyID}']`
|
||||
);
|
||||
elements.forEach(element => {
|
||||
// Replace number.
|
||||
element.querySelectorAll(".coral-count-number").forEach(no => {
|
||||
no.innerHTML = args.number;
|
||||
});
|
||||
// Replace text.
|
||||
element.querySelectorAll(".coral-count-text").forEach(no => {
|
||||
no.innerHTML = args.text;
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
export default withLiveCommentCount;
|
||||
@@ -0,0 +1,22 @@
|
||||
import { COUNT_SELECTOR } from "coral-framework/constants";
|
||||
import detectCountScript from "coral-framework/helpers/detectCountScript";
|
||||
|
||||
/**
|
||||
* injectCountScriptIfNeeded will detect if comment count injection is necessary and
|
||||
* automatically includes the `count.js` script.
|
||||
*/
|
||||
const injectCountScriptIfNeeded = (rootURL: string) => {
|
||||
if (detectCountScript()) {
|
||||
return;
|
||||
}
|
||||
// Detect if we need to inject counts.
|
||||
if (document.querySelector(COUNT_SELECTOR)) {
|
||||
const s = document.createElement("script");
|
||||
s.src = `${rootURL}/assets/js/count.js`;
|
||||
s.async = false;
|
||||
s.defer = true;
|
||||
(document.head || document.body).appendChild(s);
|
||||
}
|
||||
};
|
||||
|
||||
export default injectCountScriptIfNeeded;
|
||||
@@ -19,6 +19,9 @@
|
||||
<a href="/storyButton.html">Story With Button</a>
|
||||
</p>
|
||||
<h1 style="text-align: center">Coral 5.0 – Story</h1>
|
||||
<p>
|
||||
<a href="#coralStreamEmbed"><span class="coral-count"></span></a>
|
||||
</p>
|
||||
<p>
|
||||
Dismember a mouse and then regurgitate parts of it on the family room
|
||||
floor. Dont wait for the storm to pass, dance in the rain stand in front
|
||||
|
||||
@@ -19,6 +19,9 @@
|
||||
<a href="/story.html">Story</a>
|
||||
</p>
|
||||
<h1 style="text-align: center">Coral 5.0 – Story with Button</h1>
|
||||
<p>
|
||||
<a href="#coralStreamEmbed"><span class="coral-count"></span></a>
|
||||
</p>
|
||||
<p>
|
||||
Dismember a mouse and then regurgitate parts of it on the family room
|
||||
floor. Dont wait for the storm to pass, dance in the rain stand in front
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import mockConsole from "jest-mock-console";
|
||||
import * as Coral from "./";
|
||||
import * as Coral from "../";
|
||||
|
||||
// tslint:disable:no-console
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import mockConsole from "jest-mock-console";
|
||||
import * as Coral from "../";
|
||||
|
||||
import { COUNT_SELECTOR } from "coral-framework/constants";
|
||||
|
||||
// tslint:disable:no-console
|
||||
|
||||
describe("Basic integration test", () => {
|
||||
const container: HTMLElement = document.createElement("div");
|
||||
beforeAll(() => {
|
||||
container.id = "basic-integration-test-id";
|
||||
document.body.appendChild(container);
|
||||
});
|
||||
afterAll(() => {
|
||||
document.body.removeChild(container);
|
||||
});
|
||||
it("should not inject count script", () => {
|
||||
mockConsole();
|
||||
const CoralEmbedStream = Coral.createStreamEmbed({
|
||||
id: "basic-integration-test-id",
|
||||
});
|
||||
CoralEmbedStream.render();
|
||||
expect(document.head.querySelector("script")).toBeNull();
|
||||
});
|
||||
it("should inject count script", () => {
|
||||
mockConsole();
|
||||
const commentCount: HTMLElement = document.createElement("div");
|
||||
commentCount.className = "coral-count";
|
||||
document.body.appendChild(commentCount);
|
||||
Coral.createStreamEmbed({
|
||||
id: "basic-integration-test-id",
|
||||
});
|
||||
const s = document.head.querySelector("script");
|
||||
expect(s).not.toBeNull();
|
||||
expect(s!.defer).toBe(true);
|
||||
document.head.removeChild(s!);
|
||||
document.body.removeChild(commentCount);
|
||||
});
|
||||
it("should not inject count script when it's already there", () => {
|
||||
mockConsole();
|
||||
|
||||
// Make detectCountScript return true.
|
||||
(window as any).CoralCount = {};
|
||||
|
||||
const commentCount: HTMLElement = document.createElement("div");
|
||||
commentCount.className = COUNT_SELECTOR;
|
||||
document.body.appendChild(commentCount);
|
||||
const CoralEmbedStream = Coral.createStreamEmbed({
|
||||
id: "basic-integration-test-id",
|
||||
});
|
||||
CoralEmbedStream.render();
|
||||
expect(document.head.querySelector("script")).toBeNull();
|
||||
document.body.removeChild(commentCount);
|
||||
|
||||
delete (window as any).CoralCount;
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* COUNT_SELECTOR is a css selector used to identify elements that
|
||||
* will be replaced by the story count.
|
||||
*/
|
||||
export const COUNT_SELECTOR = ".coral-count";
|
||||
|
||||
/**
|
||||
* ORIGIN_FALLBACK_ID can be attached to any <script /> tag as an
|
||||
* id to allow the `count.js` script to find its origin when
|
||||
* `document.currentScript` is not available (for legacy browsers).
|
||||
*/
|
||||
export const ORIGIN_FALLBACK_ID = "coral-origin";
|
||||
@@ -0,0 +1,7 @@
|
||||
function detectCountScript() {
|
||||
// If CoralCount JSONP callback has been defined, then the
|
||||
// count script has already run.
|
||||
return (window as any).CoralCount !== undefined;
|
||||
}
|
||||
|
||||
export default detectCountScript;
|
||||
@@ -10,3 +10,5 @@ export {
|
||||
export { default as getParamsFromHash } from "./getParamsFromHash";
|
||||
export { default as clearHash } from "./clearHash";
|
||||
export { default as roleIsAtLeast } from "./roleIsAtLeast";
|
||||
export { default as resolveStoryURL } from "./resolveStoryURL";
|
||||
export { default as detectCountScript } from "./detectCountScript";
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { getLocationOrigin } from "coral-framework/utils";
|
||||
|
||||
function resolveStoryURL() {
|
||||
const canonical = document.querySelector(
|
||||
'link[rel="canonical"]'
|
||||
) as HTMLLinkElement;
|
||||
if (canonical) {
|
||||
return canonical.href;
|
||||
}
|
||||
|
||||
// tslint:disable-next-line:no-console
|
||||
console.warn(
|
||||
"This page does not include a canonical link tag. Coral has inferred this story_url from the window object. Query params have been stripped, which may cause a single thread to be present across multiple pages."
|
||||
);
|
||||
|
||||
return getLocationOrigin() + window.location.pathname;
|
||||
}
|
||||
|
||||
export default resolveStoryURL;
|
||||
@@ -3,3 +3,5 @@ export { default as parseURL } from "./parseURL";
|
||||
export { default as modifyQuery } from "./modifyQuery";
|
||||
export { default as areWeInIframe } from "./areWeInIframe";
|
||||
export { default as parseHashQuery } from "./parseHashQuery";
|
||||
export { default as getLocationOrigin } from "./getLocationOrigin";
|
||||
export { default as jsonp } from "./jsonp";
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Initiate a jsonp request.
|
||||
* @argument endpoint jsonp endpoint
|
||||
* @argument callback name of global callback to receive response
|
||||
* @argument args args to send along the jsonp request.
|
||||
*/
|
||||
function jsonp(
|
||||
endpoint: string,
|
||||
callback: string,
|
||||
args: Record<string, string | number | null | undefined>
|
||||
) {
|
||||
const script = document.createElement("script");
|
||||
script.src = `${endpoint}?callback=${callback}`;
|
||||
Object.keys(args).forEach(key => {
|
||||
let val = "";
|
||||
if (args[key] === undefined) {
|
||||
return;
|
||||
}
|
||||
if (typeof args[key] === "string") {
|
||||
val = args[key] as string;
|
||||
} else {
|
||||
val = JSON.stringify(args[key]);
|
||||
}
|
||||
script.src += `&${key}=${encodeURIComponent(val)}`;
|
||||
});
|
||||
document.body.appendChild(script);
|
||||
}
|
||||
|
||||
export default jsonp;
|
||||
@@ -36,6 +36,7 @@ import SortMenu from "./SortMenu";
|
||||
import StoryClosedTimeoutContainer from "./StoryClosedTimeout";
|
||||
import styles from "./StreamContainer.css";
|
||||
import { SuspendedInfoContainer } from "./SuspendedInfo/index";
|
||||
import useCommentCountEvent from "./useCommentCountEvent";
|
||||
|
||||
interface Props {
|
||||
story: StoryData;
|
||||
@@ -96,6 +97,9 @@ export const StreamContainer: FunctionComponent<Props> = props => {
|
||||
const allCommentsCount = props.story.commentCounts.totalPublished;
|
||||
const featuredCommentsCount = props.story.commentCounts.tags.FEATURED;
|
||||
|
||||
// Emit comment count event.
|
||||
useCommentCountEvent(props.story.id, props.story.url, allCommentsCount);
|
||||
|
||||
useEffect(() => {
|
||||
// If the comment tab is still in its uninitialized state, "NONE", then we
|
||||
// should evaluate that based on the featuredCommentsCount if we should show
|
||||
@@ -221,6 +225,8 @@ const enhanced = withFragmentContainer<Props>({
|
||||
...StoryClosedTimeoutContainer_story
|
||||
...CreateCommentReplyMutation_story
|
||||
...CreateCommentMutation_story
|
||||
id
|
||||
url
|
||||
commentCounts {
|
||||
totalPublished
|
||||
tags {
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { useEffectWhenChanged } from "coral-framework/hooks";
|
||||
import { useCoralContext } from "coral-framework/lib/bootstrap";
|
||||
import { getMessage } from "coral-framework/lib/i18n";
|
||||
|
||||
/**
|
||||
* useCommentCountEvent is a React hook that will
|
||||
* emit `commentCount` events.
|
||||
* @param storyID story id of the comment count
|
||||
* @param storyURL story url of the comment count
|
||||
* @param commentCount number of total published comments
|
||||
*/
|
||||
function useCommentCountEvent(
|
||||
storyID: string,
|
||||
storyURL: string,
|
||||
commentCount: number
|
||||
) {
|
||||
const { eventEmitter, localeBundles } = useCoralContext();
|
||||
const callback = () => {
|
||||
eventEmitter.emit("commentCount", {
|
||||
number: commentCount,
|
||||
text: getMessage(localeBundles, "comment-count-text", "Comment", {
|
||||
count: commentCount,
|
||||
}),
|
||||
storyID,
|
||||
storyURL,
|
||||
});
|
||||
};
|
||||
useEffect(callback, []);
|
||||
useEffectWhenChanged(callback, [
|
||||
eventEmitter,
|
||||
commentCount,
|
||||
localeBundles,
|
||||
storyID,
|
||||
storyURL,
|
||||
]);
|
||||
}
|
||||
|
||||
export default useCommentCountEvent;
|
||||
+1
-1
@@ -320,7 +320,7 @@ const ChangeUsernameContainer: FunctionComponent<Props> = ({
|
||||
Cancel
|
||||
</Button>
|
||||
</Localized>
|
||||
<Localized id="profile-changeUsername-submit-button-save">
|
||||
<Localized id="profile-changeUsername-save">
|
||||
<Button
|
||||
className={CLASSES.myUsername.form.saveButton}
|
||||
variant={pristine || invalid ? "outlined" : "filled"}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { pureMerge } from "coral-common/utils";
|
||||
import { GQLResolver } from "coral-framework/schema";
|
||||
import {
|
||||
createResolversStub,
|
||||
CreateTestRendererParams,
|
||||
} from "coral-framework/testHelpers";
|
||||
|
||||
import { commenters, settings, stories } from "../../fixtures";
|
||||
import create from "./create";
|
||||
|
||||
const story = stories[0];
|
||||
const viewer = commenters[0];
|
||||
|
||||
async function createTestRenderer(
|
||||
params: CreateTestRendererParams<GQLResolver> = {}
|
||||
) {
|
||||
const { testRenderer, context } = create({
|
||||
...params,
|
||||
resolvers: pureMerge(
|
||||
createResolversStub<GQLResolver>({
|
||||
Query: {
|
||||
settings: () => settings,
|
||||
viewer: () => viewer,
|
||||
story: () => story,
|
||||
},
|
||||
}),
|
||||
params.resolvers
|
||||
),
|
||||
initLocalState: (localRecord, source, environment) => {
|
||||
localRecord.setValue(story.id, "storyID");
|
||||
if (params.initLocalState) {
|
||||
params.initLocalState(localRecord, source, environment);
|
||||
}
|
||||
},
|
||||
});
|
||||
return {
|
||||
testRenderer,
|
||||
context,
|
||||
};
|
||||
}
|
||||
|
||||
it("emit commentCount events", done => {
|
||||
createTestRenderer().then(({ context: { eventEmitter } }) => {
|
||||
eventEmitter.on("commentCount", args => {
|
||||
expect(args).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"number": 2,
|
||||
"storyID": "story-1",
|
||||
"storyURL": "http://localhost/stories/story-1",
|
||||
"text": "Comments",
|
||||
}
|
||||
`);
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -123,9 +123,9 @@ exports[`renders configure 1`] = `
|
||||
<p
|
||||
className="Box-root Typography-root Typography-detail Typography-colorTextSecondary WidthLimitedDescription-root"
|
||||
>
|
||||
When enabled, the comments will be updated instantly
|
||||
as new comments and replies are submitted, instead of
|
||||
requiring a page refresh. You can disable this in the
|
||||
When enabled, the comments will be updated instantly
|
||||
as new comments and replies are submitted, instead of
|
||||
requiring a page refresh. You can disable this in the
|
||||
unusual situation of an article getting so much traffic that the comments are loading slowly.
|
||||
</p>
|
||||
</div>
|
||||
@@ -258,8 +258,8 @@ unusual situation of an article getting so much traffic that the comments are lo
|
||||
<p
|
||||
className="Box-root Typography-root Typography-detail Typography-colorTextSecondary WidthLimitedDescription-root"
|
||||
>
|
||||
Add a message to the top of the comment box for your readers.
|
||||
Use this to suggest a discussion topic, ask a question or make
|
||||
Add a message to the top of the comment box for your readers.
|
||||
Use this to suggest a discussion topic, ask a question or make
|
||||
announcements relating to the comments on this story.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -300,7 +300,7 @@ exports[`renders the empty settings pane 1`] = `
|
||||
className="Box-root Typography-root Typography-bodyCopy Typography-colorTextPrimary"
|
||||
>
|
||||
An email has been sent to $email to verify your account.
|
||||
You must verify your new email address before it can be used
|
||||
You must verify your new email address before it can be used
|
||||
to sign in to your account or to receive notifications.
|
||||
</p>
|
||||
<button
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"coral-account/*": ["./account/*"],
|
||||
"coral-admin/*": ["./admin/*"],
|
||||
"coral-auth/*": ["./auth/*"],
|
||||
"coral-count/*": ["./count/*"],
|
||||
"coral-embed/*": ["./embed/*"],
|
||||
"coral-stream/*": ["./stream/*"],
|
||||
"coral-framework/*": ["./framework/*"],
|
||||
|
||||
@@ -8,4 +8,3 @@ export { default as ensureNoEndSlash } from "./ensureNoEndSlash";
|
||||
export { default as parseQuery } from "./parseQuery";
|
||||
export { default as stringifyQuery } from "./stringifyQuery";
|
||||
export { default as pureMerge } from "./pureMerge";
|
||||
export { default as getLocationOrigin } from "./getLocationOrigin";
|
||||
|
||||
@@ -5,3 +5,4 @@ export * from "./health";
|
||||
export * from "./install";
|
||||
export * from "./version";
|
||||
export * from "./user";
|
||||
export * from "./story";
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { RequestHandler } from "coral-server/types/express";
|
||||
import createDOMPurify from "dompurify";
|
||||
import { JSDOM } from "jsdom";
|
||||
|
||||
import { AppOptions } from "coral-server/app";
|
||||
import { calculateTotalPublishedCommentCount } from "coral-server/models/story";
|
||||
import { translate } from "coral-server/services/i18n";
|
||||
import { find } from "coral-server/services/stories";
|
||||
|
||||
const NUMBER_CLASSNAME = "coral-count-number";
|
||||
const TEXT_CLASSNAME = "coral-count-text";
|
||||
|
||||
export type CountOptions = Pick<AppOptions, "mongo" | "tenantCache" | "i18n">;
|
||||
|
||||
/**
|
||||
* countHandler returns translated comment counts using JSONP.
|
||||
*/
|
||||
export const countHandler = ({ mongo, i18n }: CountOptions): RequestHandler => {
|
||||
const window = new JSDOM("").window;
|
||||
const DOMPurify = createDOMPurify(window);
|
||||
|
||||
return async (req, res, next) => {
|
||||
try {
|
||||
// Tenant is guaranteed at this point.
|
||||
const coral = req.coral!;
|
||||
const tenant = coral.tenant!;
|
||||
|
||||
const story = await find(mongo, tenant, {
|
||||
id: req.query.id,
|
||||
url: req.query.url,
|
||||
});
|
||||
if (!story) {
|
||||
throw new Error("Story not found");
|
||||
}
|
||||
|
||||
const count = calculateTotalPublishedCommentCount(
|
||||
story.commentCounts.status
|
||||
);
|
||||
|
||||
let html = "";
|
||||
if (req.query.notext === "true") {
|
||||
// We only need the count without the text.
|
||||
html = `<span class="${NUMBER_CLASSNAME}">${count}</span>`;
|
||||
} else {
|
||||
// Use translated string.
|
||||
const bundle = i18n.getBundle(tenant.locale);
|
||||
html = translate(
|
||||
bundle,
|
||||
`<span class="${NUMBER_CLASSNAME}">${count}</span> <span class="${TEXT_CLASSNAME}">Comments</span>`,
|
||||
"comment-count",
|
||||
{
|
||||
number: count,
|
||||
numberClass: NUMBER_CLASSNAME,
|
||||
textClass: TEXT_CLASSNAME,
|
||||
}
|
||||
);
|
||||
// Strip dangerous html from translation.
|
||||
html = DOMPurify.sanitize(html) as string;
|
||||
}
|
||||
|
||||
// Respond using jsonp.
|
||||
res.jsonp({
|
||||
// Reference from the client that we'll just send back as it is.
|
||||
ref: req.query.ref,
|
||||
html,
|
||||
});
|
||||
} catch (err) {
|
||||
return next(err);
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./count";
|
||||
@@ -18,6 +18,7 @@ import { tenantMiddleware } from "coral-server/app/middleware/tenant";
|
||||
|
||||
import { createNewAccountRouter } from "./account";
|
||||
import { createNewAuthRouter } from "./auth";
|
||||
import { createStoryRouter } from "./story";
|
||||
import { createNewUserRouter } from "./user";
|
||||
|
||||
export interface RouterOptions {
|
||||
@@ -57,6 +58,7 @@ export function createAPIRouter(app: AppOptions, options: RouterOptions) {
|
||||
router.use("/auth", createNewAuthRouter(app, options));
|
||||
router.use("/account", createNewAccountRouter(app, options));
|
||||
router.use("/user", createNewUserRouter(app));
|
||||
router.use("/story", createStoryRouter(app));
|
||||
|
||||
// Configure the GraphQL route.
|
||||
router.use(
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import express from "express";
|
||||
|
||||
import { AppOptions } from "coral-server/app";
|
||||
import { countHandler } from "coral-server/app/handlers";
|
||||
import { cacheHeadersMiddleware } from "coral-server/app/middleware/cacheHeaders";
|
||||
|
||||
export function createStoryRouter(app: AppOptions) {
|
||||
const router = express.Router();
|
||||
|
||||
// TODO: (cvle) make caching time configurable?
|
||||
router.get("/count.js", cacheHeadersMiddleware("2m"), countHandler(app));
|
||||
return router;
|
||||
}
|
||||
@@ -1,14 +1,14 @@
|
||||
import { GQLCommentCountsTypeResolver } from "coral-server/graph/tenant/schema/__generated__/types";
|
||||
import { PUBLISHED_STATUSES } from "coral-server/models/comment/constants";
|
||||
import { Story } from "coral-server/models/story";
|
||||
import {
|
||||
calculateTotalPublishedCommentCount,
|
||||
Story,
|
||||
} from "coral-server/models/story";
|
||||
|
||||
export type CommentCountsInput = Pick<Story, "commentCounts" | "id">;
|
||||
|
||||
export const CommentCounts: GQLCommentCountsTypeResolver<CommentCountsInput> = {
|
||||
totalPublished: ({ commentCounts }) =>
|
||||
PUBLISHED_STATUSES.reduce(
|
||||
(total, status) => total + commentCounts.status[status],
|
||||
0
|
||||
),
|
||||
calculateTotalPublishedCommentCount(commentCounts.status),
|
||||
statuses: ({ commentCounts }) => commentCounts.status,
|
||||
tags: (s, input, ctx) => ctx.loaders.Comments.tagCounts.load(s.id),
|
||||
};
|
||||
|
||||
@@ -4,3 +4,10 @@ disableCommentingDefaultMessage = Comments are closed on this story.
|
||||
reaction-labelRespect = Respect
|
||||
reaction-labelActiveRespected = Respected
|
||||
reaction-sortLabelMostRespected = Most Respected
|
||||
|
||||
comment-count =
|
||||
<span class="{ $numberClass }">{ $number }</span>
|
||||
<span class="{ $textClass }">{ $number ->
|
||||
[one] Comment
|
||||
*[other] Comments
|
||||
}</span>
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
GQLTAG,
|
||||
} from "coral-server/graph/tenant/schema/__generated__/types";
|
||||
|
||||
import { calculateTotalPublishedCommentCount } from "../story";
|
||||
import { Comment } from "./comment";
|
||||
import { MODERATOR_STATUSES, PUBLISHED_STATUSES } from "./constants";
|
||||
import { Revision } from "./revision";
|
||||
@@ -68,10 +69,7 @@ export function createEmptyCommentStatusCounts(): CommentStatusCounts {
|
||||
}
|
||||
|
||||
export function calculateRejectionRate(counts: CommentStatusCounts): number {
|
||||
const published = PUBLISHED_STATUSES.reduce(
|
||||
(acc, status) => counts[status] + acc,
|
||||
0
|
||||
);
|
||||
const published = calculateTotalPublishedCommentCount(counts);
|
||||
const rejected = counts[GQLCOMMENT_STATUS.REJECTED];
|
||||
|
||||
return rejected / (published + rejected);
|
||||
|
||||
@@ -9,6 +9,7 @@ import { dotize } from "coral-common/utils/dotize";
|
||||
import { GQLCOMMENT_STATUS } from "coral-server/graph/tenant/schema/__generated__/types";
|
||||
import logger from "coral-server/logger";
|
||||
import { EncodedCommentActionCounts } from "coral-server/models/action/comment";
|
||||
import { PUBLISHED_STATUSES } from "coral-server/models/comment/constants";
|
||||
import {
|
||||
CommentStatusCounts,
|
||||
createEmptyCommentStatusCounts,
|
||||
@@ -226,3 +227,16 @@ export function calculateTotalCommentCount(
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* calculateTotalPublishedCommentCount will compute the total amount of
|
||||
* published comments in a story by parsing the `CommentStatusCounts`.
|
||||
*/
|
||||
export function calculateTotalPublishedCommentCount(
|
||||
commentCounts: CommentStatusCounts
|
||||
) {
|
||||
return PUBLISHED_STATUSES.reduce(
|
||||
(total, status) => total + commentCounts[status],
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,13 +16,21 @@ general-tabBar-commentsTab = Comments
|
||||
general-tabBar-myProfileTab = My Profile
|
||||
general-tabBar-configure = Configure
|
||||
|
||||
## Comment Count
|
||||
|
||||
comment-count-text =
|
||||
{ $number ->
|
||||
[one] Comment
|
||||
*[other] Comments
|
||||
}
|
||||
|
||||
## Comments Tab
|
||||
|
||||
comments-allCommentsTab = All Comments
|
||||
comments-featuredTab = Featured
|
||||
comments-featuredCommentTooltip-how = How is a comment featured?
|
||||
comments-featuredCommentTooltip-handSelectedComments =
|
||||
Comments are chosen by our team as worth reading.
|
||||
Comments are chosen by our team as worth reading.
|
||||
comments-featuredCommentTooltip-toggleButton =
|
||||
.aria-label = Toggle featured comments tooltip
|
||||
|
||||
@@ -197,9 +205,9 @@ profile-commentHistory-empty-subheading = A history of your comments will appear
|
||||
### Account
|
||||
profile-account-ignoredCommenters = Ignored Commenters
|
||||
profile-account-ignoredCommenters-description =
|
||||
You can Ignore other commenters by clicking on their username
|
||||
and selecting Ignore. When you ignore someone, all of their
|
||||
comments are hidden from you. Commenters you Ignore will still
|
||||
You can Ignore other commenters by clicking on their username
|
||||
and selecting Ignore. When you ignore someone, all of their
|
||||
comments are hidden from you. Commenters you Ignore will still
|
||||
be able to see your comments.
|
||||
profile-account-ignoredCommenters-empty = You are not currently ignoring anyone
|
||||
profile-account-ignoredCommenters-stopIgnoring = Stop ignoring
|
||||
@@ -369,8 +377,7 @@ profile-changeUsername-current = Current username
|
||||
profile-changeUsername-newUsername-label = New username
|
||||
profile-changeUsername-confirmNewUsername-label = Confirm new username
|
||||
profile-changeUsername-cancel = Cancel
|
||||
profile-changeUsername-submit = <ButtonIcon>save</ButtonIcon> <span>Save</span>
|
||||
profile-changeUsername-submit-button = Save
|
||||
profile-changeUsername-save = Save
|
||||
profile-changeUsername-recentChange = Your username has been changed in the last { framework-timeago-time }. You may change your username again on { $nextUpdate }
|
||||
profile-changeUsername-close = Close
|
||||
|
||||
@@ -388,15 +395,15 @@ configure-premodLink-description =
|
||||
|
||||
configure-liveUpdates-title = Enable Live Updates for this Story
|
||||
configure-liveUpdates-description =
|
||||
When enabled, the comments will be updated instantly
|
||||
as new comments and replies are submitted, instead of
|
||||
requiring a page refresh. You can disable this in the
|
||||
When enabled, the comments will be updated instantly
|
||||
as new comments and replies are submitted, instead of
|
||||
requiring a page refresh. You can disable this in the
|
||||
unusual situation of an article getting so much traffic that the comments are loading slowly.
|
||||
|
||||
configure-messageBox-title = Enable Message Box for this Story
|
||||
configure-messageBox-description =
|
||||
Add a message to the top of the comment box for your readers.
|
||||
Use this to suggest a discussion topic, ask a question or make
|
||||
Add a message to the top of the comment box for your readers.
|
||||
Use this to suggest a discussion topic, ask a question or make
|
||||
announcements relating to the comments on this story.
|
||||
configure-messageBox-preview = Preview
|
||||
configure-messageBox-selectAnIcon = Select an Icon
|
||||
@@ -432,7 +439,7 @@ profile-changeEmail-edit = Edit
|
||||
profile-changeEmail-please-verify = Verify your email address
|
||||
profile-changeEmail-please-verify-details =
|
||||
An email has been sent to { $email } to verify your account.
|
||||
You must verify your new email address before it can be used
|
||||
You must verify your new email address before it can be used
|
||||
to sign in to your account or to receive notifications.
|
||||
profile-changeEmail-resend = Resend verification
|
||||
profile-changeEmail-heading = Edit your email address
|
||||
|
||||
Reference in New Issue
Block a user