[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:
Vinh
2019-10-02 02:22:15 +07:00
committed by Wyatt Johnson
parent 53fa5f43e5
commit 808b355a27
48 changed files with 888 additions and 50 deletions
+1
View File
@@ -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",
+39
View File
@@ -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,
})
),
]),
},
];
}
+3
View File
@@ -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 sites 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;
+23
View File
@@ -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>
+61
View File
@@ -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&notext=false&ref=ZmFsc2U7aHR0cDovL2xvY2FsaG9zdDo4MDgwL3N0b3J5Lmh0bWw%3D"
/>
<script
src="http://localhost:8080/api/story/count.js?callback=CoralCount.setCount&id=1234-5678-91021&notext=false&ref=ZmFsc2U7MTIzNC01Njc4LTkxMDIx"
/>
<script
src="http://localhost:8080/api/story/count.js?callback=CoralCount.setCount&url=http%3A%2F%2Flocalhost%3A8080%2F&notext=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&notext=false&ref=ZmFsc2U7aHR0cDovL2xvY2FsaG9zdDo4MDgwL3N0b3J5Lmh0bWw%3D"
/>
<script
src="http://localhost:8080/api/story/count.js?callback=CoralCount.setCount&id=1234-5678-91021&notext=false&ref=ZmFsc2U7MTIzNC01Njc4LTkxMDIx"
/>
<script
src="http://localhost:8080/api/story/count.js?callback=CoralCount.setCount&url=http%3A%2F%2Flocalhost%3A8080%2F&notext=false&ref=ZmFsc2U7aHR0cDovL2xvY2FsaG9zdDo4MDgwLw%3D%3D"
/>
</body>
`;
+53
View File
@@ -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();
});
+3 -17
View File
@@ -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);
+7
View File
@@ -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;
+3
View File
@@ -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>&nbsp;
</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
+3
View File
@@ -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>&nbsp;
</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
+57
View File
@@ -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;
});
});
+12
View File
@@ -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;
+2
View File
@@ -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";
+29
View File
@@ -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;
@@ -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
+1
View File
@@ -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/*"],
-1
View File
@@ -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";
+2
View File
@@ -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(
+13
View File
@@ -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),
};
+7
View File
@@ -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>
+2 -4
View File
@@ -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
);
}
+19 -12
View File
@@ -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