mirror of
https://github.com/wassname/talk.git
synced 2026-09-13 13:10:43 +08:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5acac741e4 | ||
|
|
8c6c8c1f72 | ||
|
|
3d9f8bd9ea | ||
|
|
27d6f1c245 | ||
|
|
f41623bbd4 |
Generated
+4
-4
@@ -5087,13 +5087,13 @@
|
||||
}
|
||||
},
|
||||
"@coralproject/rte": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@coralproject/rte/-/rte-1.2.3.tgz",
|
||||
"integrity": "sha512-1H5HsidtrwS3+1e3oRe7ZQFHi93mAZOmlg9eIi/oOlzLsMszOZbODYL5YL3Exh2KrV2VCbgS69vxvBlGYkXP0A==",
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@coralproject/rte/-/rte-1.1.1.tgz",
|
||||
"integrity": "sha512-r70xg7arHttiJr4pXCZTTWA86bjT9M+XHDeaYjNpMQrW5rIL+kw7sjoRuiyPLzi88xINMbxffurdKep2Z68bcg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"classnames": "^2.2.6",
|
||||
"squire-rte": "^1.10.2"
|
||||
"squire-rte": "^1.9.0"
|
||||
}
|
||||
},
|
||||
"@csstools/convert-colors": {
|
||||
|
||||
+1
-1
@@ -152,7 +152,7 @@
|
||||
"@babel/preset-typescript": "^7.10.1",
|
||||
"@babel/runtime-corejs3": "^7.10.3",
|
||||
"@coralproject/npm-run-all": "^4.1.5",
|
||||
"@coralproject/rte": "^1.2.3",
|
||||
"@coralproject/rte": "^1.1.1",
|
||||
"@fluent/react": "^0.11.1",
|
||||
"@intervolga/optimize-cssnano-plugin": "^1.0.6",
|
||||
"@types/archiver": "^3.1.0",
|
||||
|
||||
@@ -19,6 +19,7 @@ async function main() {
|
||||
const ManagedCoralContextProvider = await createManaged({
|
||||
initLocalState,
|
||||
localesData,
|
||||
bundle: "account",
|
||||
});
|
||||
|
||||
const Index: FunctionComponent = () => (
|
||||
|
||||
@@ -20,6 +20,7 @@ async function main() {
|
||||
const ManagedCoralContextProvider = await createManaged({
|
||||
initLocalState,
|
||||
localesData,
|
||||
bundle: "admin",
|
||||
});
|
||||
|
||||
const Index: FunctionComponent = () => (
|
||||
|
||||
@@ -13,10 +13,18 @@
|
||||
color: var(--palette-text-100);
|
||||
}
|
||||
|
||||
.metaElement {
|
||||
margin-right: var(--spacing-1);
|
||||
}
|
||||
|
||||
.authorName {
|
||||
font-weight: var(--font-weight-primary-semi-bold);
|
||||
}
|
||||
|
||||
.readingNow {
|
||||
color: var(--palette-success-500);
|
||||
}
|
||||
|
||||
.reportedCountColumn,
|
||||
.pendingCountColumn,
|
||||
.totalCountColumn,
|
||||
|
||||
@@ -32,6 +32,7 @@ interface Props {
|
||||
reportedCount: number | null;
|
||||
pendingCount: number | null;
|
||||
totalCount: number;
|
||||
viewerCount: number | null;
|
||||
}
|
||||
|
||||
const UserRow: FunctionComponent<Props> = (props) => (
|
||||
@@ -50,10 +51,23 @@ const UserRow: FunctionComponent<Props> = (props) => (
|
||||
props.title || <NotAvailable />
|
||||
)}
|
||||
</p>
|
||||
{(props.author || props.publishDate) && (
|
||||
{(props.author || props.publishDate || !!props.viewerCount) && (
|
||||
<p className={styles.meta}>
|
||||
<span className={styles.authorName}>{props.author}</span>{" "}
|
||||
{props.publishDate}
|
||||
{!!props.author && (
|
||||
<span className={cn(styles.authorName, styles.metaElement)}>
|
||||
{props.author}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{!!props.publishDate && (
|
||||
<span className={styles.metaElement}>{props.publishDate} </span>
|
||||
)}
|
||||
|
||||
{!!props.viewerCount && (
|
||||
<span className={styles.readingNow}>
|
||||
{props.viewerCount} reading now
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</HorizontalGutter>
|
||||
|
||||
@@ -53,6 +53,7 @@ const StoryRowContainer: FunctionComponent<Props> = (props) => {
|
||||
reportedCount={props.story.moderationQueues.reported.count}
|
||||
pendingCount={props.story.moderationQueues.pending.count}
|
||||
publishDate={publishedAt}
|
||||
viewerCount={props.story.viewerCount}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -83,6 +84,7 @@ const enhanced = withFragmentContainer<Props>({
|
||||
count
|
||||
}
|
||||
}
|
||||
viewerCount
|
||||
site {
|
||||
name
|
||||
id
|
||||
|
||||
@@ -205,12 +205,16 @@ exports[`renders empty stories 1`] = `
|
||||
className="StoryRow-meta"
|
||||
>
|
||||
<span
|
||||
className="StoryRow-authorName"
|
||||
className="StoryRow-authorName StoryRow-metaElement"
|
||||
>
|
||||
Vin Hoa
|
||||
</span>
|
||||
|
||||
11/29/2018, 4:01 PM
|
||||
<span
|
||||
className="StoryRow-metaElement"
|
||||
>
|
||||
11/29/2018, 4:01 PM
|
||||
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
@@ -301,12 +305,16 @@ exports[`renders empty stories 1`] = `
|
||||
className="StoryRow-meta"
|
||||
>
|
||||
<span
|
||||
className="StoryRow-authorName"
|
||||
className="StoryRow-authorName StoryRow-metaElement"
|
||||
>
|
||||
Linh Nguyen
|
||||
</span>
|
||||
|
||||
11/29/2018, 4:01 PM
|
||||
<span
|
||||
className="StoryRow-metaElement"
|
||||
>
|
||||
11/29/2018, 4:01 PM
|
||||
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
@@ -587,12 +595,16 @@ exports[`renders stories 1`] = `
|
||||
className="StoryRow-meta"
|
||||
>
|
||||
<span
|
||||
className="StoryRow-authorName"
|
||||
className="StoryRow-authorName StoryRow-metaElement"
|
||||
>
|
||||
Vin Hoa
|
||||
</span>
|
||||
|
||||
11/29/2018, 4:01 PM
|
||||
<span
|
||||
className="StoryRow-metaElement"
|
||||
>
|
||||
11/29/2018, 4:01 PM
|
||||
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
@@ -683,12 +695,16 @@ exports[`renders stories 1`] = `
|
||||
className="StoryRow-meta"
|
||||
>
|
||||
<span
|
||||
className="StoryRow-authorName"
|
||||
className="StoryRow-authorName StoryRow-metaElement"
|
||||
>
|
||||
Linh Nguyen
|
||||
</span>
|
||||
|
||||
11/29/2018, 4:01 PM
|
||||
<span
|
||||
className="StoryRow-metaElement"
|
||||
>
|
||||
11/29/2018, 4:01 PM
|
||||
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
@@ -19,6 +19,7 @@ async function main() {
|
||||
const ManagedCoralContextProvider = await createManaged({
|
||||
initLocalState,
|
||||
localesData,
|
||||
bundle: "auth",
|
||||
});
|
||||
|
||||
const Index: FunctionComponent = () => (
|
||||
|
||||
@@ -7,3 +7,5 @@ export { default as useUUID } from "./useUUID";
|
||||
export { default as useToken } from "./useToken";
|
||||
export { default as useResizeObserver } from "./useResizeObserver";
|
||||
export { default as useToggleState } from "./useToggleState";
|
||||
export { default as useLive } from "./useLive";
|
||||
export { default as useVisibilityState } from "./useVisibilityState";
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useMemo } from "react";
|
||||
|
||||
interface Props {
|
||||
story: {
|
||||
isClosed: boolean;
|
||||
settings: {
|
||||
live: {
|
||||
enabled: boolean;
|
||||
};
|
||||
};
|
||||
};
|
||||
settings: {
|
||||
disableCommenting: {
|
||||
enabled: boolean;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
const useLive = ({ story, settings }: Props) =>
|
||||
useMemo(() => {
|
||||
if (
|
||||
// If live updates are disable for this story...
|
||||
!story.settings.live.enabled ||
|
||||
// Or the story is closed...
|
||||
story.isClosed ||
|
||||
// Or commenting is disabled...
|
||||
settings.disableCommenting.enabled
|
||||
) {
|
||||
// Then we aren't live!
|
||||
return false;
|
||||
}
|
||||
|
||||
// The story is open! Mark the story as open.
|
||||
return true;
|
||||
}, [
|
||||
// When used in conjunction with the StoryClosedTimeoutContainer, we don't
|
||||
// have to inspect the `story.closedAt` because it'll update the store for
|
||||
// us!
|
||||
story.isClosed,
|
||||
settings.disableCommenting.enabled,
|
||||
story.settings.live.enabled,
|
||||
]);
|
||||
|
||||
export default useLive;
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
function isVisible(state: VisibilityState) {
|
||||
return state === "visible";
|
||||
}
|
||||
|
||||
/**
|
||||
* useVisibilityState renders the `document.visibilityState` as a hook that will
|
||||
* stay synced to the document's events associated with changes, so you can
|
||||
* safely use the returned value as dependancies for other hooks involving
|
||||
* visiblity.
|
||||
*/
|
||||
function useVisibilityState() {
|
||||
const [state, setState] = useState(isVisible(document.visibilityState));
|
||||
|
||||
useEffect(() => {
|
||||
// update will set the visibility state if it differs from the current react
|
||||
// state.
|
||||
const update = () => {
|
||||
const current = isVisible(document.visibilityState);
|
||||
if (state !== current) {
|
||||
setState(current);
|
||||
}
|
||||
};
|
||||
|
||||
// Update it now!
|
||||
update();
|
||||
|
||||
// Register for when that changes!
|
||||
document.addEventListener("visibilitychange", update);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("visibilitychange", update);
|
||||
};
|
||||
}, [state]);
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
export default useVisibilityState;
|
||||
@@ -59,6 +59,12 @@ interface CreateContextArguments {
|
||||
|
||||
/** Supports emitting and listening to events. */
|
||||
eventEmitter?: EventEmitter2;
|
||||
|
||||
/** bundle is the specific source of the connection */
|
||||
bundle: string;
|
||||
|
||||
/** bundleConfig is the configuration parameters for this bundle */
|
||||
bundleConfig?: Record<string, string>;
|
||||
}
|
||||
|
||||
/** websocketURL points to our live graphql server */
|
||||
@@ -271,6 +277,8 @@ export default async function createManaged({
|
||||
localesData,
|
||||
pym,
|
||||
eventEmitter = new EventEmitter2({ wildcard: true, maxListeners: 20 }),
|
||||
bundle,
|
||||
bundleConfig = {},
|
||||
}: CreateContextArguments): Promise<ComponentType> {
|
||||
// Listen for outside clicks.
|
||||
let registerClickFarAway: ClickFarAwayRegister | undefined;
|
||||
@@ -318,7 +326,9 @@ export default async function createManaged({
|
||||
|
||||
const subscriptionClient = createManagedSubscriptionClient(
|
||||
websocketURL,
|
||||
clientID
|
||||
clientID,
|
||||
bundle,
|
||||
bundleConfig
|
||||
);
|
||||
|
||||
const { environment, accessTokenProvider } = createRelayEnvironment(
|
||||
|
||||
@@ -9,7 +9,12 @@ import {
|
||||
SubscriptionClient,
|
||||
} from "subscriptions-transport-ws";
|
||||
|
||||
import { ACCESS_TOKEN_PARAM, CLIENT_ID_PARAM } from "coral-common/constants";
|
||||
import {
|
||||
ACCESS_TOKEN_PARAM,
|
||||
BUNDLE_CONFIG_PARAM,
|
||||
BUNDLE_ID_PARAM,
|
||||
CLIENT_ID_PARAM,
|
||||
} from "coral-common/constants";
|
||||
import { ERROR_CODES } from "coral-common/errors";
|
||||
|
||||
/**
|
||||
@@ -61,7 +66,9 @@ export interface ManagedSubscriptionClient {
|
||||
*/
|
||||
export default function createManagedSubscriptionClient(
|
||||
url: string,
|
||||
clientID: string
|
||||
clientID: string,
|
||||
bundle: string,
|
||||
bundleConfig: Record<string, string>
|
||||
): ManagedSubscriptionClient {
|
||||
const requests: SubscriptionRequest[] = [];
|
||||
let subscriptionClient: SubscriptionClient | null = null;
|
||||
@@ -114,6 +121,8 @@ export default function createManagedSubscriptionClient(
|
||||
connectionParams: {
|
||||
[ACCESS_TOKEN_PARAM]: accessToken,
|
||||
[CLIENT_ID_PARAM]: clientID,
|
||||
[BUNDLE_ID_PARAM]: bundle,
|
||||
[BUNDLE_CONFIG_PARAM]: bundleConfig,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ async function main() {
|
||||
const ManagedCoralContextProvider = await createManaged({
|
||||
localesData,
|
||||
initLocalState,
|
||||
bundle: "install",
|
||||
});
|
||||
|
||||
const Index: FunctionComponent = () => (
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Child as PymChild } from "pym.js";
|
||||
import React, { FunctionComponent } from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
|
||||
import { parseQuery } from "coral-common/utils";
|
||||
import injectConditionalPolyfills from "coral-framework/helpers/injectConditionalPolyfills";
|
||||
import potentiallyInjectAxe from "coral-framework/helpers/potentiallyInjectAxe";
|
||||
import { createManaged } from "coral-framework/lib/bootstrap";
|
||||
@@ -13,6 +14,11 @@ import localesData from "./locales";
|
||||
// Import css variables.
|
||||
import "coral-ui/theme/stream.css";
|
||||
|
||||
function extractBundleConfig() {
|
||||
const { storyID, storyURL } = parseQuery(location.search);
|
||||
return { storyID, storyURL } as Record<string, string>;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const pym = new PymChild({
|
||||
polling: 100,
|
||||
@@ -20,10 +26,17 @@ async function main() {
|
||||
await injectConditionalPolyfills();
|
||||
// Potentially inject react-axe for runtime a11y checks.
|
||||
await potentiallyInjectAxe(pym.parentUrl);
|
||||
|
||||
// Detect and extract the storyID and storyURL from the current page so we can
|
||||
// add it to the managed provider.
|
||||
const bundleConfig = extractBundleConfig();
|
||||
|
||||
const ManagedCoralContextProvider = await createManaged({
|
||||
initLocalState,
|
||||
localesData,
|
||||
pym,
|
||||
bundle: "stream",
|
||||
bundleConfig,
|
||||
});
|
||||
|
||||
const Index: FunctionComponent = () => (
|
||||
|
||||
@@ -10,7 +10,6 @@ exports[`renders correctly 1`] = `
|
||||
contentContainerClassName="RTE-container coral coral-rte-container"
|
||||
contentContainerClassNameDisabled=""
|
||||
features={Array []}
|
||||
linkContentMatchHref={true}
|
||||
placeholder="Post a comment"
|
||||
placeholderClassName="coral coral-rte-placeholder RTE-placeholder"
|
||||
placeholderClassNameDisabled=""
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { clearLongTimeout } from "long-settimeout";
|
||||
import React, { FunctionComponent, useCallback, useEffect } from "react";
|
||||
import { graphql, GraphQLTaggedNode, RelayPaginationProp } from "react-relay";
|
||||
import { withProps } from "recompose";
|
||||
|
||||
import { createTimeoutAt } from "coral-common/utils";
|
||||
import { useLive } from "coral-framework/hooks";
|
||||
import { useViewerNetworkEvent } from "coral-framework/lib/events";
|
||||
import {
|
||||
useLoadMore,
|
||||
@@ -80,17 +79,16 @@ export const ReplyListContainer: React.FunctionComponent<Props> = (props) => {
|
||||
const subcribeToCommentReplyCreated = useSubscription(
|
||||
CommentReplyCreatedSubscription
|
||||
);
|
||||
|
||||
const live = useLive(props);
|
||||
useEffect(() => {
|
||||
// If the comment is pending, no need to subscribe the comment!
|
||||
if (props.comment.pending) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!props.story.settings.live.enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (props.story.isClosed || props.settings.disableCommenting.enabled) {
|
||||
// If live updates aren't enabled, don't subscribe!
|
||||
if (!live) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -103,37 +101,16 @@ export const ReplyListContainer: React.FunctionComponent<Props> = (props) => {
|
||||
liveDirectRepliesInsertion: props.liveDirectRepliesInsertion,
|
||||
});
|
||||
|
||||
// If the story is scheduled to be closed, cancel the subscriptions because
|
||||
// we can't add any more comments!
|
||||
if (props.story.closedAt) {
|
||||
const timer = createTimeoutAt(() => {
|
||||
disposable.dispose();
|
||||
}, props.story.closedAt);
|
||||
|
||||
return () => {
|
||||
// Cancel the timer if there was one enabled.
|
||||
if (timer) {
|
||||
clearLongTimeout(timer);
|
||||
}
|
||||
|
||||
// Dispose the subscriptions.
|
||||
disposable.dispose();
|
||||
};
|
||||
}
|
||||
|
||||
return () => {
|
||||
disposable.dispose();
|
||||
};
|
||||
}, [
|
||||
live,
|
||||
subcribeToCommentReplyCreated,
|
||||
props.comment.id,
|
||||
props.indentLevel,
|
||||
props.comment.pending,
|
||||
props.settings.disableCommenting.enabled,
|
||||
props.liveDirectRepliesInsertion,
|
||||
props.story.isClosed,
|
||||
props.story.closedAt,
|
||||
props.story.settings.live.enabled,
|
||||
]);
|
||||
|
||||
const viewNew = useMutation(ReplyListViewNewMutation);
|
||||
|
||||
+10
-35
@@ -1,11 +1,10 @@
|
||||
import { Localized } from "@fluent/react/compat";
|
||||
import cn from "classnames";
|
||||
import { clearLongTimeout } from "long-settimeout";
|
||||
import React, { FunctionComponent, useCallback, useEffect } from "react";
|
||||
import { graphql, RelayPaginationProp } from "react-relay";
|
||||
|
||||
import { createTimeoutAt } from "coral-common/utils";
|
||||
import FadeInTransition from "coral-framework/components/FadeInTransition";
|
||||
import { useLive } from "coral-framework/hooks";
|
||||
import { useViewerNetworkEvent } from "coral-framework/lib/events";
|
||||
import {
|
||||
combineDisposables,
|
||||
@@ -73,22 +72,19 @@ export const AllCommentsTabContainer: FunctionComponent<Props> = ({
|
||||
const subscribeToCommentReleased = useSubscription(
|
||||
CommentReleasedSubscription
|
||||
);
|
||||
|
||||
const live = useLive({ story, settings });
|
||||
const hasMore = relay.hasMore();
|
||||
useEffect(() => {
|
||||
// If live updates are disabled, don't subscribe to new comments!!
|
||||
if (!story.settings.live.enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If the story is closed or commenting is disabled, then don't subscribe
|
||||
// to new comments because there isn't any!
|
||||
if (story.isClosed || settings.disableCommenting.enabled) {
|
||||
if (!live) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check the sort ordering to apply extra logic.
|
||||
switch (commentsOrderBy) {
|
||||
case GQLCOMMENT_SORT.CREATED_AT_ASC:
|
||||
if (relay.hasMore()) {
|
||||
if (hasMore) {
|
||||
// Oldest first when there is more than one page of content can't
|
||||
// possibly have new comments to show in view!
|
||||
return;
|
||||
@@ -116,37 +112,16 @@ export const AllCommentsTabContainer: FunctionComponent<Props> = ({
|
||||
})
|
||||
);
|
||||
|
||||
// If the story is scheduled to be closed, cancel the subscriptions because
|
||||
// we can't add any more comments!
|
||||
if (story.closedAt) {
|
||||
const timer = createTimeoutAt(() => {
|
||||
disposable.dispose();
|
||||
}, story.closedAt);
|
||||
|
||||
return () => {
|
||||
// Cancel the timer if there was one enabled.
|
||||
if (timer) {
|
||||
clearLongTimeout(timer);
|
||||
}
|
||||
|
||||
// Dispose the subscriptions.
|
||||
disposable.dispose();
|
||||
};
|
||||
}
|
||||
|
||||
return () => {
|
||||
disposable.dispose();
|
||||
};
|
||||
}, [
|
||||
commentsOrderBy,
|
||||
hasMore,
|
||||
live,
|
||||
story.id,
|
||||
subscribeToCommentCreated,
|
||||
subscribeToCommentReleased,
|
||||
story.id,
|
||||
story.isClosed,
|
||||
story.closedAt,
|
||||
story.settings.live.enabled,
|
||||
settings.disableCommenting.enabled,
|
||||
relay.hasMore(),
|
||||
]);
|
||||
|
||||
const [loadMore, isLoadingMore] = useLoadMore(relay, 20);
|
||||
@@ -245,7 +220,7 @@ export const AllCommentsTabContainer: FunctionComponent<Props> = ({
|
||||
</FadeInTransition>
|
||||
</IgnoredTombstoneOrHideContainer>
|
||||
))}
|
||||
{relay.hasMore() && (
|
||||
{hasMore && (
|
||||
<Localized id="comments-loadMore">
|
||||
<Button
|
||||
onClick={loadMoreAndEmit}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { graphql } from "react-relay";
|
||||
import { Environment } from "relay-runtime";
|
||||
|
||||
import {
|
||||
createFetch,
|
||||
fetchQuery,
|
||||
FetchVariables,
|
||||
} from "coral-framework/lib/relay";
|
||||
|
||||
import { RefreshStoryViewerCountQuery as QueryTypes } from "coral-stream/__generated__/RefreshStoryViewerCountQuery.graphql";
|
||||
|
||||
const RefreshStoryViewerCount = createFetch(
|
||||
"refreshStoryViewerCount",
|
||||
(environment: Environment, variables: FetchVariables<QueryTypes>) => {
|
||||
return fetchQuery<QueryTypes>(
|
||||
environment,
|
||||
graphql`
|
||||
query RefreshStoryViewerCountQuery($storyID: ID!) {
|
||||
settings {
|
||||
...ViewersWatchingContainer_settings
|
||||
}
|
||||
story(id: $storyID) {
|
||||
...ViewersWatchingContainer_story
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables,
|
||||
{ force: true }
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export default RefreshStoryViewerCount;
|
||||
@@ -10,6 +10,7 @@ import { graphql } from "react-relay";
|
||||
|
||||
import { useCoralContext } from "coral-framework/lib/bootstrap";
|
||||
import { useViewerEvent } from "coral-framework/lib/events";
|
||||
import { IntersectionProvider } from "coral-framework/lib/intersection";
|
||||
import { useLocal, withFragmentContainer } from "coral-framework/lib/relay";
|
||||
import { GQLSTORY_MODE, GQLUSER_STATUS } from "coral-framework/schema";
|
||||
import CLASSES from "coral-stream/classes";
|
||||
@@ -54,6 +55,7 @@ import StoryClosedTimeoutContainer from "./StoryClosedTimeout";
|
||||
import { SuspendedInfoContainer } from "./SuspendedInfo/index";
|
||||
import UnansweredCommentsTab from "./UnansweredCommentsTab";
|
||||
import useCommentCountEvent from "./useCommentCountEvent";
|
||||
import ViewersWatchingContainer from "./ViewersWatchingContainer";
|
||||
import WarningContainer from "./Warning";
|
||||
|
||||
import styles from "./StreamContainer.css";
|
||||
@@ -227,6 +229,12 @@ export const StreamContainer: FunctionComponent<Props> = (props) => {
|
||||
settings={props.settings}
|
||||
/>
|
||||
)}
|
||||
<IntersectionProvider>
|
||||
<ViewersWatchingContainer
|
||||
story={props.story}
|
||||
settings={props.settings}
|
||||
/>
|
||||
</IntersectionProvider>
|
||||
<HorizontalGutter spacing={4} className={styles.tabBarContainer}>
|
||||
<Flex
|
||||
direction="row"
|
||||
@@ -416,6 +424,7 @@ const enhanced = withFragmentContainer<Props>({
|
||||
...CreateCommentReplyMutation_story
|
||||
...CreateCommentMutation_story
|
||||
...ModerateStreamContainer_story
|
||||
...ViewersWatchingContainer_story
|
||||
id
|
||||
url
|
||||
settings {
|
||||
@@ -457,6 +466,7 @@ const enhanced = withFragmentContainer<Props>({
|
||||
...AnnouncementContainer_settings
|
||||
...ModerateStreamContainer_settings
|
||||
...WarningContainer_settings
|
||||
...ViewersWatchingContainer_settings
|
||||
}
|
||||
`,
|
||||
})(StreamContainer);
|
||||
|
||||
+50
-38
@@ -3,8 +3,10 @@ import React, { FunctionComponent, useCallback, useEffect } from "react";
|
||||
import { graphql, RelayPaginationProp } from "react-relay";
|
||||
|
||||
import FadeInTransition from "coral-framework/components/FadeInTransition";
|
||||
import { useLive } from "coral-framework/hooks";
|
||||
import { useViewerNetworkEvent } from "coral-framework/lib/events";
|
||||
import {
|
||||
combineDisposables,
|
||||
useLoadMore,
|
||||
useLocal,
|
||||
useMutation,
|
||||
@@ -27,8 +29,8 @@ import { UnansweredCommentsTabContainerPaginationQueryVariables } from "coral-st
|
||||
import { CommentContainer } from "../../Comment";
|
||||
import IgnoredTombstoneOrHideContainer from "../../IgnoredTombstoneOrHideContainer";
|
||||
import { ReplyListContainer } from "../../ReplyList";
|
||||
import CommentCreatedSubscription from "./UnansweredCommentCreatedSubscription";
|
||||
import CommentReleasedSubscription from "./UnansweredCommentReleasedSubscription";
|
||||
import UnansweredCommentCreatedSubscription from "./UnansweredCommentCreatedSubscription";
|
||||
import UnansweredCommentReleasedSubscription from "./UnansweredCommentReleasedSubscription";
|
||||
import UnansweredCommentsTabViewNewMutation from "./UnansweredCommentsTabViewNewMutation";
|
||||
|
||||
import styles from "./UnansweredCommentsTabContainer.css";
|
||||
@@ -60,55 +62,65 @@ export const UnansweredCommentsTabContainer: FunctionComponent<Props> = (
|
||||
}
|
||||
`
|
||||
);
|
||||
const subscribeToCommentCreated = useSubscription(CommentCreatedSubscription);
|
||||
const subscribeToCommentReleased = useSubscription(
|
||||
CommentReleasedSubscription
|
||||
|
||||
const subscribeToCommentCreated = useSubscription(
|
||||
UnansweredCommentCreatedSubscription
|
||||
);
|
||||
const subscribeToCommentReleased = useSubscription(
|
||||
UnansweredCommentReleasedSubscription
|
||||
);
|
||||
|
||||
const live = useLive(props);
|
||||
const hasMore = props.relay.hasMore();
|
||||
useEffect(() => {
|
||||
if (!props.story.settings.live.enabled) {
|
||||
// If live updates are disabled, don't subscribe to new comments!!
|
||||
if (!live) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (props.story.isClosed || props.settings.disableCommenting.enabled) {
|
||||
return;
|
||||
// Check the sort ordering to apply extra logic.
|
||||
switch (commentsOrderBy) {
|
||||
case GQLCOMMENT_SORT.CREATED_AT_ASC:
|
||||
if (hasMore) {
|
||||
// Oldest first when there is more than one page of content can't
|
||||
// possibly have new comments to show in view!
|
||||
return;
|
||||
}
|
||||
|
||||
// We have all the comments for this story in view! Comments could load!
|
||||
break;
|
||||
case GQLCOMMENT_SORT.CREATED_AT_DESC:
|
||||
// Newest first can always get more comments in view.
|
||||
break;
|
||||
default:
|
||||
// Only chronological sort supports top level live updates of incoming
|
||||
// comments.
|
||||
return;
|
||||
}
|
||||
if (
|
||||
commentsOrderBy === GQLCOMMENT_SORT.CREATED_AT_ASC &&
|
||||
props.relay.hasMore()
|
||||
) {
|
||||
// If sort by oldest we only need to know if there is more to load.
|
||||
return;
|
||||
}
|
||||
if (
|
||||
![
|
||||
GQLCOMMENT_SORT.CREATED_AT_ASC,
|
||||
GQLCOMMENT_SORT.CREATED_AT_DESC,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
|
||||
].includes(commentsOrderBy as GQLCOMMENT_SORT)
|
||||
) {
|
||||
// Only chronological sort supports top level live updates of incoming comments.
|
||||
return;
|
||||
}
|
||||
const newCommentDisposable = subscribeToCommentCreated({
|
||||
storyID: props.story.id,
|
||||
orderBy: commentsOrderBy,
|
||||
});
|
||||
const releasedCommentDisposable = subscribeToCommentReleased({
|
||||
storyID: props.story.id,
|
||||
orderBy: commentsOrderBy,
|
||||
});
|
||||
|
||||
const disposable = combineDisposables(
|
||||
subscribeToCommentCreated({
|
||||
storyID: props.story.id,
|
||||
orderBy: commentsOrderBy,
|
||||
}),
|
||||
subscribeToCommentReleased({
|
||||
storyID: props.story.id,
|
||||
orderBy: commentsOrderBy,
|
||||
})
|
||||
);
|
||||
|
||||
return () => {
|
||||
newCommentDisposable.dispose();
|
||||
releasedCommentDisposable.dispose();
|
||||
disposable.dispose();
|
||||
};
|
||||
}, [
|
||||
commentsOrderBy,
|
||||
hasMore,
|
||||
live,
|
||||
props.story.id,
|
||||
subscribeToCommentCreated,
|
||||
subscribeToCommentReleased,
|
||||
props.story.id,
|
||||
props.relay.hasMore(),
|
||||
props.story.settings.live.enabled,
|
||||
]);
|
||||
|
||||
const [loadMore, isLoadingMore] = useLoadMore(props.relay, 20);
|
||||
const beginLoadMoreEvent = useViewerNetworkEvent(LoadMoreAllCommentsEvent);
|
||||
const loadMoreAndEmit = useCallback(async () => {
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
$start-color: var(--palette-success-500);
|
||||
$end-color: var(--palette-success-400);
|
||||
|
||||
@keyframes color {
|
||||
0% {
|
||||
color: $start-color;
|
||||
}
|
||||
50% {
|
||||
color: $end-color;
|
||||
}
|
||||
100% {
|
||||
color: $start-color;
|
||||
}
|
||||
}
|
||||
|
||||
.title,
|
||||
.icon {
|
||||
animation: color 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.icon {
|
||||
flex-basis: calc(var(--spacing-3) + var(--spacing-1) + 8px);
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import { Localized } from "@fluent/react/compat";
|
||||
import React, {
|
||||
FunctionComponent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useState,
|
||||
} from "react";
|
||||
import { graphql } from "react-relay";
|
||||
|
||||
import { useLive, useVisibilityState } from "coral-framework/hooks";
|
||||
import { withInView } from "coral-framework/lib/intersection";
|
||||
import { useFetch, withFragmentContainer } from "coral-framework/lib/relay";
|
||||
import { Icon } from "coral-ui/components/v2";
|
||||
import { CallOut } from "coral-ui/components/v3";
|
||||
|
||||
import { ViewersWatchingContainer_settings } from "coral-stream/__generated__/ViewersWatchingContainer_settings.graphql";
|
||||
import { ViewersWatchingContainer_story } from "coral-stream/__generated__/ViewersWatchingContainer_story.graphql";
|
||||
|
||||
import RefreshStoryViewerCount from "./RefreshStoryViewerCount";
|
||||
|
||||
import styles from "./ViewersWatchingContainer.css";
|
||||
|
||||
interface Props {
|
||||
inView: boolean | undefined;
|
||||
intersectionRef: React.Ref<any>;
|
||||
story: ViewersWatchingContainer_story;
|
||||
settings: ViewersWatchingContainer_settings;
|
||||
}
|
||||
|
||||
const TIMEOUT = 20000;
|
||||
const TIMEOUT_JITTER = TIMEOUT / 2;
|
||||
const MAX_TIMEOUT = TIMEOUT + TIMEOUT_JITTER;
|
||||
|
||||
const ViewersWatchingContainer: FunctionComponent<Props> = ({
|
||||
story,
|
||||
settings,
|
||||
inView = false,
|
||||
intersectionRef,
|
||||
}) => {
|
||||
const [lastRefreshed, setLastRefreshed] = useState<number>(Date.now());
|
||||
const [refreshed, setRefreshed] = useState(false);
|
||||
const live = useLive({ story, settings });
|
||||
const visible = useVisibilityState();
|
||||
const refreshStoryViewerCount = useFetch(RefreshStoryViewerCount);
|
||||
|
||||
// refresh will refresh the viewer count by refetching the data via the Graph.
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
// Refresh the viewer count!
|
||||
await refreshStoryViewerCount({ storyID: story.id });
|
||||
|
||||
// Mark that we've refreshed (so we remove the extra +1).
|
||||
setRefreshed(true);
|
||||
|
||||
// Mark the current date so it'll schedule the next timeout to run in the
|
||||
// following useEffect.
|
||||
setLastRefreshed(Date.now());
|
||||
} catch (err) {
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("couldn not refresh the story viewer count:", err);
|
||||
}
|
||||
}
|
||||
}, [refreshStoryViewerCount, story.id]);
|
||||
|
||||
// available will be true when the viewer count is available.
|
||||
const available = story.viewerCount !== null;
|
||||
|
||||
useEffect(() => {
|
||||
// If we aren't live, or there isn't a live count available (like if the
|
||||
// feature flag isn't enabled), then we don't have to do anything! If the
|
||||
// element isn't visible or the page isn't in the foreground, also halt
|
||||
// updates.
|
||||
if (!live || !available || !visible || !inView) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the time between now and the last time we updated. This addresses the
|
||||
// issue where the timer was cleared and not reset because it was out of
|
||||
// view so it'll fire right now.
|
||||
const lastRefreshedDiff = Date.now() - lastRefreshed;
|
||||
if (lastRefreshedDiff >= MAX_TIMEOUT) {
|
||||
// The difference was greater than the max timeout. Fire the refresh right
|
||||
// now.
|
||||
void refresh();
|
||||
return;
|
||||
}
|
||||
|
||||
const timeout = window.setTimeout(
|
||||
refresh,
|
||||
// Start with the max timeout...
|
||||
MAX_TIMEOUT -
|
||||
// Then subtract the difference from the last refresh date...
|
||||
lastRefreshedDiff +
|
||||
// And add a random jitter to help spread out the calls.
|
||||
Math.floor(Math.random() * TIMEOUT_JITTER)
|
||||
);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timeout);
|
||||
};
|
||||
}, [
|
||||
live,
|
||||
story.id,
|
||||
refreshStoryViewerCount,
|
||||
visible,
|
||||
available,
|
||||
inView,
|
||||
lastRefreshed,
|
||||
refresh,
|
||||
]);
|
||||
|
||||
// If we aren't live or the viewer count isn't available, then return nothing!
|
||||
if (!live || story.viewerCount === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// We always add one for the current viewer!
|
||||
const viewerCount = refreshed ? story.viewerCount : story.viewerCount + 1;
|
||||
|
||||
return (
|
||||
<div ref={intersectionRef}>
|
||||
<CallOut
|
||||
classes={{ icon: styles.icon, title: styles.title }}
|
||||
icon={<Icon size="md">play_circle_filled</Icon>}
|
||||
title={
|
||||
<Localized id="comments-watchers" $count={viewerCount}>
|
||||
<span>{viewerCount} people is online</span>
|
||||
</Localized>
|
||||
}
|
||||
titleWeight="semiBold"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const enhanced = withInView(
|
||||
withFragmentContainer<Props>({
|
||||
story: graphql`
|
||||
fragment ViewersWatchingContainer_story on Story {
|
||||
id
|
||||
viewerCount
|
||||
isClosed
|
||||
settings {
|
||||
live {
|
||||
enabled
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
settings: graphql`
|
||||
fragment ViewersWatchingContainer_settings on Settings {
|
||||
disableCommenting {
|
||||
enabled
|
||||
}
|
||||
}
|
||||
`,
|
||||
})(ViewersWatchingContainer)
|
||||
);
|
||||
|
||||
export default enhanced;
|
||||
@@ -13,6 +13,19 @@ export const CLIENT_ID_HEADER = "X-Coral-Client-ID";
|
||||
*/
|
||||
export const CLIENT_ID_PARAM = "clientID";
|
||||
|
||||
/**
|
||||
* BUNDLE_ID_PARAM references the name of the param used ot send the ID of the
|
||||
* bundle via connectionParams when connecting via a websocket connection.
|
||||
*/
|
||||
export const BUNDLE_ID_PARAM = "bundleID";
|
||||
|
||||
/**
|
||||
* BUNDLE_CONFIG_PARAM references the name of the param used to send the
|
||||
* parameters of the bundle via connectionParams when connecting via a websocket
|
||||
* connection.
|
||||
*/
|
||||
export const BUNDLE_CONFIG_PARAM = "bundleConfig";
|
||||
|
||||
/**
|
||||
* ACCESS_TOKEN_PARAM references the name of the param used to send the access
|
||||
* token in connectionParams when authenticating a websocket connection.
|
||||
|
||||
@@ -64,8 +64,8 @@ export const graphQLHandler = ({
|
||||
// Add the clientID if there is one on the request.
|
||||
const clientID = req.get(CLIENT_ID_HEADER);
|
||||
if (clientID) {
|
||||
// TODO: (wyattjoh) validate length
|
||||
opts.clientID = clientID;
|
||||
// Limit the clientID to 36 characters (the length of a UUID).
|
||||
opts.clientID = clientID.slice(0, 36);
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -262,6 +262,13 @@ const config = convict({
|
||||
default: ms("800 milliseconds"),
|
||||
env: "PERSPECTIVE_TIMEOUT",
|
||||
},
|
||||
story_viewer_timeout: {
|
||||
doc:
|
||||
"The length of time (in ms) that a user should be considered active on a story without interaction.",
|
||||
format: "ms",
|
||||
default: ms("15 minutes"),
|
||||
env: "STORY_VIEWER_TIMEOUT",
|
||||
},
|
||||
force_ssl: {
|
||||
doc:
|
||||
"Forces SSL in production by redirecting all HTTP requests to HTTPS, and sending HSTS headers.",
|
||||
|
||||
@@ -3,4 +3,5 @@ export * from "./notifier";
|
||||
export * from "./perspective";
|
||||
export * from "./slack";
|
||||
export * from "./subscription";
|
||||
export * from "./viewers";
|
||||
export * from "./webhook";
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { createStoryViewer } from "coral-server/models/story/viewers";
|
||||
import { hasFeatureFlag } from "coral-server/models/tenant";
|
||||
|
||||
import { GQLFEATURE_FLAG } from "coral-server/graph/schema/__generated__/types";
|
||||
|
||||
import {
|
||||
CommentCreatedCoralEventPayload,
|
||||
CommentReactionCreatedCoralEventPayload,
|
||||
CommentReplyCreatedCoralEventPayload,
|
||||
} from "../events";
|
||||
import { CoralEventListener, CoralEventPublisherFactory } from "../publisher";
|
||||
import { CoralEventType } from "../types";
|
||||
|
||||
type ViewersCoralEventListenerPayloads =
|
||||
| CommentReplyCreatedCoralEventPayload
|
||||
| CommentCreatedCoralEventPayload
|
||||
| CommentReactionCreatedCoralEventPayload;
|
||||
|
||||
export class ViewersCoralEventListener
|
||||
implements CoralEventListener<ViewersCoralEventListenerPayloads> {
|
||||
public readonly name = "viewers";
|
||||
public readonly events = [
|
||||
CoralEventType.COMMENT_REPLY_CREATED,
|
||||
CoralEventType.COMMENT_CREATED,
|
||||
CoralEventType.COMMENT_REACTION_CREATED,
|
||||
];
|
||||
|
||||
public initialize: CoralEventPublisherFactory<
|
||||
ViewersCoralEventListenerPayloads
|
||||
> = ({ clientID, redis, tenant, config }) => async ({ data }) => {
|
||||
if (!clientID) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If the feature flag isn't enabled, then we have nothing to do!
|
||||
if (!hasFeatureFlag(tenant, GQLFEATURE_FLAG.VIEWER_COUNT)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await createStoryViewer(
|
||||
redis,
|
||||
{
|
||||
tenantID: tenant.id,
|
||||
siteID: data.siteID,
|
||||
storyID: data.storyID,
|
||||
},
|
||||
clientID,
|
||||
config.get("story_viewer_timeout")
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -9,4 +9,5 @@ export enum CoralEventType {
|
||||
STORY_CREATED = "STORY_CREATED",
|
||||
COMMENT_REACTION_CREATED = "COMMENT_REACTION_CREATED",
|
||||
COMMENT_FLAG_CREATED = "COMMENT_FLAG_CREATED",
|
||||
LIVE_STORY_VIEWERS_UPDATE = "LIVE_STORY_VIEWERS_UPDATE",
|
||||
}
|
||||
|
||||
@@ -1,51 +1,12 @@
|
||||
import { isUndefined } from "lodash";
|
||||
import { DateTime } from "luxon";
|
||||
|
||||
import * as settings from "coral-server/models/settings";
|
||||
import { Story } from "coral-server/models/story";
|
||||
import { isStoryLiveEnabled } from "coral-server/services/stories";
|
||||
|
||||
import { GQLLiveConfigurationTypeResolver } from "coral-server/graph/schema/__generated__/types";
|
||||
|
||||
export interface LiveConfigurationInput extends settings.LiveConfiguration {
|
||||
lastCommentedAt?: Date;
|
||||
createdAt?: Date;
|
||||
}
|
||||
export type LiveConfigurationInput = Story;
|
||||
|
||||
export const LiveConfiguration: GQLLiveConfigurationTypeResolver<LiveConfigurationInput> = {
|
||||
configurable: (source, args, ctx) =>
|
||||
Boolean(!ctx.config.get("disable_live_updates")),
|
||||
enabled: (source, args, ctx) => {
|
||||
if (ctx.config.get("disable_live_updates")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const disableLiveUpdatesTimeout = ctx.config.get(
|
||||
"disable_live_updates_timeout"
|
||||
);
|
||||
if (disableLiveUpdatesTimeout > 0) {
|
||||
// If one of these is available, use it to determine the time since the
|
||||
// last comment.
|
||||
const lastCommentedAt = source.lastCommentedAt || source.createdAt;
|
||||
if (
|
||||
// If a date is found...
|
||||
lastCommentedAt &&
|
||||
// And the date (when we add the timeout duration) is before the current
|
||||
// date...
|
||||
DateTime.fromJSDate(lastCommentedAt)
|
||||
.plus({
|
||||
milliseconds: disableLiveUpdatesTimeout,
|
||||
})
|
||||
.toJSDate() <= ctx.now
|
||||
) {
|
||||
// Then we know that the last comment (or lack there of) was left more
|
||||
// than the timeout specified in configuration.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (isUndefined(source.enabled)) {
|
||||
return ctx.tenant.live.enabled;
|
||||
}
|
||||
|
||||
return source.enabled;
|
||||
},
|
||||
configurable: (source, args, ctx) => !ctx.config.get("disable_live_updates"),
|
||||
enabled: (source, args, ctx) =>
|
||||
isStoryLiveEnabled(ctx.config, ctx.tenant, source, ctx.now),
|
||||
};
|
||||
|
||||
@@ -2,11 +2,13 @@ import { defaultsDeep } from "lodash";
|
||||
|
||||
import { decodeActionCounts } from "coral-server/models/action/comment";
|
||||
import * as story from "coral-server/models/story";
|
||||
import { countStoryViewers } from "coral-server/models/story/viewers";
|
||||
import { hasFeatureFlag } from "coral-server/models/tenant";
|
||||
import {
|
||||
canModerate,
|
||||
hasModeratorRole,
|
||||
} from "coral-server/models/user/helpers";
|
||||
import { isStoryLiveEnabled } from "coral-server/services/stories";
|
||||
|
||||
import {
|
||||
GQLFEATURE_FLAG,
|
||||
@@ -49,14 +51,34 @@ export const Story: GQLStoryTypeResolver<story.Story> = {
|
||||
// options if they exist.
|
||||
settings: (s, input, ctx): StorySettingsInput =>
|
||||
defaultsDeep(
|
||||
{
|
||||
// Pass these options as required by StorySettingsInput.
|
||||
lastCommentedAt: s.lastCommentedAt,
|
||||
createdAt: s.createdAt,
|
||||
},
|
||||
// Pass these options as required by StorySettingsInput.
|
||||
{ story: s },
|
||||
s.settings,
|
||||
ctx.tenant
|
||||
),
|
||||
moderationQueues: storyModerationInputResolver,
|
||||
site: (s, input, ctx) => ctx.loaders.Sites.site.load(s.siteID),
|
||||
viewerCount: async (s, input, ctx) => {
|
||||
// If the feature flag isn't enabled, then we have nothing to return.
|
||||
if (!hasFeatureFlag(ctx.tenant, GQLFEATURE_FLAG.VIEWER_COUNT)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check to see if this story has live enabled.
|
||||
const liveEnabled = isStoryLiveEnabled(ctx.config, ctx.tenant, s, ctx.now);
|
||||
if (!liveEnabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Return the computed count!
|
||||
return countStoryViewers(
|
||||
ctx.redis,
|
||||
{
|
||||
tenantID: ctx.tenant.id,
|
||||
siteID: s.siteID,
|
||||
storyID: s.id,
|
||||
},
|
||||
ctx.config.get("story_viewer_timeout")
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -5,18 +5,13 @@ import { GQLStorySettingsTypeResolver } from "../schema/__generated__/types";
|
||||
import { LiveConfigurationInput } from "./LiveConfiguration";
|
||||
|
||||
export interface StorySettingsInput extends story.StorySettings {
|
||||
lastCommentedAt?: Date;
|
||||
createdAt?: Date;
|
||||
story: story.Story;
|
||||
}
|
||||
|
||||
export const StorySettings: GQLStorySettingsTypeResolver<StorySettingsInput> = {
|
||||
live: (s): LiveConfigurationInput => ({
|
||||
// Live may not be available sometimes, fix it here with the inline ||.
|
||||
...(s.live || { enabled: false }),
|
||||
// Pass these options as required by LiveConfigurationInput.
|
||||
lastCommentedAt: s.lastCommentedAt,
|
||||
createdAt: s.createdAt,
|
||||
}),
|
||||
export const StorySettings: Required<GQLStorySettingsTypeResolver<
|
||||
StorySettingsInput
|
||||
>> = {
|
||||
live: (s): LiveConfigurationInput => s.story,
|
||||
moderation: (s, input, ctx) => s.moderation || ctx.tenant.moderation,
|
||||
premodLinksEnable: (s, input, ctx) =>
|
||||
s.premodLinksEnable || ctx.tenant.premodLinksEnable,
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
|
||||
export interface CommentCreatedInput extends SubscriptionPayload {
|
||||
storyID: string;
|
||||
siteID: string;
|
||||
commentID: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ export interface CommentReplyCreatedInput extends SubscriptionPayload {
|
||||
ancestorIDs: string[];
|
||||
commentID: string;
|
||||
storyID: string;
|
||||
siteID: string;
|
||||
}
|
||||
|
||||
export type CommentReplyCreatedSubscription = SubscriptionType<
|
||||
|
||||
@@ -8,7 +8,7 @@ import { commentReleased } from "./commentReleased";
|
||||
import { commentReplyCreated } from "./commentReplyCreated";
|
||||
import { commentStatusUpdated } from "./commentStatusUpdated";
|
||||
|
||||
export const Subscription: GQLSubscriptionTypeResolver = {
|
||||
export const Subscription: Required<GQLSubscriptionTypeResolver> = {
|
||||
commentCreated,
|
||||
commentEnteredModerationQueue,
|
||||
commentLeftModerationQueue,
|
||||
|
||||
@@ -418,6 +418,12 @@ enum FEATURE_FLAG {
|
||||
"read more of this conversation" in the comment stream.
|
||||
"""
|
||||
READ_MORE_NEW_TAB
|
||||
|
||||
"""
|
||||
VIEWER_COUNT when true will enable the display and tracking of the viewer
|
||||
count.
|
||||
"""
|
||||
VIEWER_COUNT
|
||||
}
|
||||
|
||||
# The moderation mode of the site.
|
||||
@@ -3448,6 +3454,11 @@ type Story {
|
||||
site is the site associated with the story
|
||||
"""
|
||||
site: Site!
|
||||
|
||||
"""
|
||||
viewerCount is the number of viewers active on this Story.
|
||||
"""
|
||||
viewerCount: Int
|
||||
}
|
||||
|
||||
"""
|
||||
|
||||
@@ -14,7 +14,12 @@ import {
|
||||
SubscriptionServer,
|
||||
} from "subscriptions-transport-ws";
|
||||
|
||||
import { ACCESS_TOKEN_PARAM, CLIENT_ID_PARAM } from "coral-common/constants";
|
||||
import {
|
||||
ACCESS_TOKEN_PARAM,
|
||||
BUNDLE_CONFIG_PARAM,
|
||||
BUNDLE_ID_PARAM,
|
||||
CLIENT_ID_PARAM,
|
||||
} from "coral-common/constants";
|
||||
import { RequireProperty } from "coral-common/types";
|
||||
import { AppOptions } from "coral-server/app";
|
||||
import { getHostname } from "coral-server/app/helpers/hostname";
|
||||
@@ -34,10 +39,19 @@ import { getOperationMetadata } from "coral-server/graph/extensions/helpers";
|
||||
import { getPersistedQuery } from "coral-server/graph/persisted";
|
||||
import logger from "coral-server/logger";
|
||||
import { PersistedQuery } from "coral-server/models/queries";
|
||||
import {
|
||||
createStoryViewer,
|
||||
removeStoryViewer,
|
||||
} from "coral-server/models/story/viewers";
|
||||
import { hasFeatureFlag } from "coral-server/models/tenant";
|
||||
import { hasStaffRole } from "coral-server/models/user/helpers";
|
||||
import { extractTokenFromRequest } from "coral-server/services/jwt";
|
||||
import { find } from "coral-server/services/stories";
|
||||
|
||||
import { GQLUSER_ROLE } from "coral-server/graph/schema/__generated__/types";
|
||||
import {
|
||||
GQLFEATURE_FLAG,
|
||||
GQLUSER_ROLE,
|
||||
} from "coral-server/graph/schema/__generated__/types";
|
||||
|
||||
import GraphContext, { GraphContextOptions } from "../context";
|
||||
|
||||
@@ -47,6 +61,8 @@ type OnConnectFn = (
|
||||
context: ConnectionContext
|
||||
) => Promise<GraphContext>;
|
||||
|
||||
type OnDisconnectFn = (socket: any, context: ConnectionContext) => void;
|
||||
|
||||
export function extractTokenFromWSRequest(
|
||||
connectionParams: OperationMessagePayload,
|
||||
req: IncomingMessage
|
||||
@@ -68,12 +84,60 @@ export function extractClientID(connectionParams: OperationMessagePayload) {
|
||||
typeof connectionParams[CLIENT_ID_PARAM] === "string" &&
|
||||
connectionParams[CLIENT_ID_PARAM].length > 0
|
||||
) {
|
||||
return connectionParams[CLIENT_ID_PARAM];
|
||||
// Limit the clientID to 36 characters (the length of a UUID).
|
||||
return connectionParams[CLIENT_ID_PARAM].slice(0, 36);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function extractBundleID(
|
||||
connectionParams: OperationMessagePayload
|
||||
): string | null {
|
||||
if (
|
||||
typeof connectionParams[BUNDLE_ID_PARAM] === "string" &&
|
||||
connectionParams[BUNDLE_ID_PARAM].length > 0
|
||||
) {
|
||||
return connectionParams[BUNDLE_ID_PARAM];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function extractBundleConfig(
|
||||
connectionParams: OperationMessagePayload
|
||||
): null | Record<string, string> {
|
||||
if (typeof connectionParams[BUNDLE_CONFIG_PARAM] === "object") {
|
||||
return connectionParams[BUNDLE_CONFIG_PARAM];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function hasStoryViewer(
|
||||
socket: any
|
||||
): socket is {
|
||||
tenantID: string;
|
||||
siteID: string;
|
||||
storyID: string;
|
||||
clientID: string;
|
||||
} {
|
||||
if (
|
||||
typeof socket.tenantID === "string" &&
|
||||
socket.tenantID.length > 0 &&
|
||||
typeof socket.siteID === "string" &&
|
||||
socket.siteID.length > 0 &&
|
||||
typeof socket.storyID === "string" &&
|
||||
socket.storyID.length > 0 &&
|
||||
typeof socket.clientID === "string" &&
|
||||
socket.clientID.length > 0
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export type OnConnectOptions = RequireProperty<
|
||||
Omit<GraphContextOptions, "tenant" | "disableCaching">,
|
||||
"signingConfig"
|
||||
@@ -86,6 +150,8 @@ export function onConnect(options: OnConnectOptions): OnConnectFn {
|
||||
|
||||
// Return the per-connection operation.
|
||||
return async (connectionParams, socket) => {
|
||||
logger.trace("a socket has connected");
|
||||
|
||||
try {
|
||||
// Pull the upgrade request off of the connection.
|
||||
const req: IncomingMessage = socket.upgradeReq;
|
||||
@@ -141,7 +207,57 @@ export function onConnect(options: OnConnectOptions): OnConnectFn {
|
||||
opts.clientID = clientID;
|
||||
}
|
||||
|
||||
return new GraphContext(opts);
|
||||
// Create the GraphContext.
|
||||
const ctx = new GraphContext(opts);
|
||||
|
||||
// Get the bundleID and bundleConfig.
|
||||
const bundleID = extractBundleID(connectionParams);
|
||||
const bundleConfig = extractBundleConfig(connectionParams);
|
||||
|
||||
// Check to see if we have the viewer count feature flag enabled.
|
||||
const enabled = hasFeatureFlag(tenant, GQLFEATURE_FLAG.VIEWER_COUNT);
|
||||
|
||||
if (
|
||||
// If this tenant has this feature flag enabled...
|
||||
enabled &&
|
||||
// And the request has a clientID...
|
||||
clientID &&
|
||||
// And it's from the stream...
|
||||
bundleID === "stream" &&
|
||||
// And it has a bundle config...
|
||||
bundleConfig &&
|
||||
// And we have either a storyID or storyURL on the config...
|
||||
(bundleConfig.storyID || bundleConfig.storyURL)
|
||||
) {
|
||||
// Then we need to create a new storyViewerf for the request!
|
||||
const story = await find(options.mongo, tenant, {
|
||||
id: bundleConfig.storyID,
|
||||
url: bundleConfig.storyURL,
|
||||
});
|
||||
if (story) {
|
||||
// Attach the clientID to the socket so the disconnect handler can use
|
||||
// it to disconnect this clientID.
|
||||
socket.tenantID = tenant.id;
|
||||
socket.siteID = story.siteID;
|
||||
socket.storyID = story.id;
|
||||
socket.clientID = clientID;
|
||||
|
||||
// Create the viewer entry!
|
||||
await createStoryViewer(
|
||||
options.redis,
|
||||
{
|
||||
tenantID: tenant.id,
|
||||
siteID: story.siteID,
|
||||
storyID: story.id,
|
||||
},
|
||||
clientID,
|
||||
options.config.get("story_viewer_timeout"),
|
||||
ctx.now
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return ctx;
|
||||
} catch (err) {
|
||||
if (err instanceof LiveUpdatesDisabled) {
|
||||
logger.info({ err }, "websocket connection rejected");
|
||||
@@ -165,6 +281,30 @@ export function onConnect(options: OnConnectOptions): OnConnectFn {
|
||||
};
|
||||
}
|
||||
|
||||
export type OnDisconnectOptions = RequireProperty<
|
||||
Omit<GraphContextOptions, "tenant" | "disableCaching">,
|
||||
"redis" | "pubsub"
|
||||
>;
|
||||
|
||||
function onDisconnect(options: OnDisconnectOptions): OnDisconnectFn {
|
||||
return async (socket) => {
|
||||
logger.trace("a socket has disconnected");
|
||||
|
||||
// If the socket has a clientID attached, then remove the story viewer
|
||||
// entry.
|
||||
if (hasStoryViewer(socket)) {
|
||||
const { tenantID, siteID, storyID } = socket;
|
||||
|
||||
await removeStoryViewer(
|
||||
options.redis,
|
||||
{ tenantID, siteID, storyID },
|
||||
socket.clientID,
|
||||
options.config.get("story_viewer_timeout")
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export type FormatResponseOptions = Pick<AppOptions, "metrics">;
|
||||
|
||||
export function formatResponse(
|
||||
@@ -252,7 +392,9 @@ export function onOperation(options: OnOperationOptions) {
|
||||
};
|
||||
}
|
||||
|
||||
export type Options = OnConnectOptions & OnOperationOptions;
|
||||
export type Options = OnConnectOptions &
|
||||
OnDisconnectOptions &
|
||||
OnOperationOptions;
|
||||
|
||||
export function createSubscriptionServer(
|
||||
server: http.Server,
|
||||
@@ -267,6 +409,7 @@ export function createSubscriptionServer(
|
||||
execute,
|
||||
subscribe,
|
||||
onConnect: onConnect(options),
|
||||
onDisconnect: onDisconnect(options),
|
||||
onOperation: onOperation(options),
|
||||
keepAlive,
|
||||
},
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
PerspectiveCoralEventListener,
|
||||
SlackCoralEventListener,
|
||||
SubscriptionCoralEventListener,
|
||||
ViewersCoralEventListener,
|
||||
WebhookCoralEventListener,
|
||||
} from "./events/listeners";
|
||||
import CoralEventListenerBroker from "./events/publisher";
|
||||
@@ -221,6 +222,7 @@ class Server {
|
||||
this.broker.register(new NotifierCoralEventListener(this.tasks.notifier));
|
||||
this.broker.register(new SlackCoralEventListener());
|
||||
this.broker.register(new SubscriptionCoralEventListener());
|
||||
this.broker.register(new ViewersCoralEventListener());
|
||||
this.broker.register(new WebhookCoralEventListener(this.tasks.webhook));
|
||||
this.broker.register(new PerspectiveCoralEventListener());
|
||||
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { Redis } from "ioredis";
|
||||
|
||||
import { createTimer } from "coral-server/helpers";
|
||||
import logger from "coral-server/logger";
|
||||
|
||||
interface KeySpec {
|
||||
tenantID: string;
|
||||
siteID: string;
|
||||
storyID: string;
|
||||
}
|
||||
|
||||
function formatKey({ tenantID, siteID, storyID }: KeySpec, time: number) {
|
||||
return `storyViewers:${tenantID}:${siteID}:${storyID}:${time}`;
|
||||
}
|
||||
|
||||
function formatTime(now: Date, precision: number): number {
|
||||
return Math.floor(now.getTime() / precision);
|
||||
}
|
||||
|
||||
function calculateReadKey(spec: KeySpec, precision: number, now: Date) {
|
||||
return formatKey(spec, formatTime(now, precision));
|
||||
}
|
||||
|
||||
interface Keys {
|
||||
/**
|
||||
* current is the key for the current time.
|
||||
*/
|
||||
current: string;
|
||||
|
||||
/**
|
||||
* next is the key for the next time.
|
||||
*/
|
||||
next: string;
|
||||
}
|
||||
|
||||
function calculateWriteKeys(spec: KeySpec, precision: number, now: Date): Keys {
|
||||
return {
|
||||
current: formatKey(spec, formatTime(now, precision)),
|
||||
next: formatKey(spec, formatTime(now, precision) + 1),
|
||||
};
|
||||
}
|
||||
|
||||
export async function createStoryViewer(
|
||||
redis: Redis,
|
||||
spec: KeySpec,
|
||||
clientID: string,
|
||||
precision: number,
|
||||
now = new Date()
|
||||
): Promise<number> {
|
||||
const timer = createTimer();
|
||||
|
||||
// Compute the key for this entry.
|
||||
const { current, next } = calculateWriteKeys(spec, precision, now);
|
||||
|
||||
// Add a new viewer to the set, and expire it after the precision.
|
||||
const multi = redis.multi(); // O(1)
|
||||
|
||||
// Add the new viewer to the set...
|
||||
multi.sadd(current, clientID); // O(1)
|
||||
|
||||
// And expire the entire set after the precision time...
|
||||
multi.pexpire(current, precision); // O(1)
|
||||
|
||||
// Add the new viewer to the next set...
|
||||
multi.sadd(next, clientID); // O(1)
|
||||
|
||||
// And expire that entire set after twice the precision time...
|
||||
multi.pexpire(next, precision * 2); // O(1)
|
||||
|
||||
// Get the current count.
|
||||
multi.scard(current); // O(1)
|
||||
|
||||
// Do this now.
|
||||
const [, , , , [, count]] = await multi.exec();
|
||||
|
||||
logger.info({ took: timer(), count, spec }, "created story viewer");
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
export async function removeStoryViewer(
|
||||
redis: Redis,
|
||||
spec: KeySpec,
|
||||
clientID: string,
|
||||
precision: number,
|
||||
now = new Date()
|
||||
): Promise<number> {
|
||||
const timer = createTimer();
|
||||
|
||||
// Compute the key for this entry.
|
||||
const { current, next } = calculateWriteKeys(spec, precision, now);
|
||||
|
||||
const multi = redis.multi();
|
||||
|
||||
// Remove this clientID from the current and next set.
|
||||
multi.srem(current, clientID); // O(1)
|
||||
multi.srem(next, clientID); // O(1)
|
||||
|
||||
// Get the current count.
|
||||
multi.scard(current); // O(1)
|
||||
|
||||
// Do this now.
|
||||
const [, , [, count]] = await multi.exec();
|
||||
|
||||
logger.info({ took: timer(), count, spec }, "removed story viewer");
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
export async function countStoryViewers(
|
||||
redis: Redis,
|
||||
spec: KeySpec,
|
||||
precision: number,
|
||||
now = new Date()
|
||||
) {
|
||||
const timer = createTimer();
|
||||
|
||||
// Compute the key for this entry.
|
||||
const key = calculateReadKey(spec, precision, now);
|
||||
|
||||
// Count the number of clientID's.
|
||||
const count = await redis.scard(key);
|
||||
|
||||
logger.info({ took: timer(), count, spec }, "counted story viewers");
|
||||
|
||||
return count;
|
||||
}
|
||||
@@ -46,25 +46,27 @@ export async function publishCommentStatusChanges(
|
||||
|
||||
export async function publishCommentReplyCreated(
|
||||
broker: CoralEventPublisherBroker,
|
||||
comment: Pick<Comment, "id" | "status" | "storyID" | "ancestorIDs">
|
||||
comment: Pick<Comment, "id" | "status" | "storyID" | "ancestorIDs" | "siteID">
|
||||
) {
|
||||
if (getDepth(comment) > 0 && hasPublishedStatus(comment)) {
|
||||
await CommentReplyCreatedCoralEvent.publish(broker, {
|
||||
ancestorIDs: comment.ancestorIDs,
|
||||
commentID: comment.id,
|
||||
storyID: comment.storyID,
|
||||
siteID: comment.siteID,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function publishCommentCreated(
|
||||
broker: CoralEventPublisherBroker,
|
||||
comment: Pick<Comment, "id" | "storyID" | "parentID" | "status">
|
||||
comment: Pick<Comment, "id" | "storyID" | "parentID" | "status" | "siteID">
|
||||
) {
|
||||
if (!comment.parentID && hasPublishedStatus(comment)) {
|
||||
await CommentCreatedCoralEvent.publish(broker, {
|
||||
commentID: comment.id,
|
||||
storyID: comment.storyID,
|
||||
siteID: comment.siteID,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { uniq } from "lodash";
|
||||
import { DateTime } from "luxon";
|
||||
import { Db } from "mongodb";
|
||||
|
||||
import isNonNullArray from "coral-common/helpers/isNonNullArray";
|
||||
@@ -424,3 +425,42 @@ export async function retrieveSections(mongo: Db, tenant: Tenant) {
|
||||
|
||||
return retrieveStorySections(mongo, tenant.id);
|
||||
}
|
||||
|
||||
export async function isStoryLiveEnabled(
|
||||
config: Config,
|
||||
tenant: Tenant,
|
||||
story: Story,
|
||||
now: Date
|
||||
) {
|
||||
if (config.get("disable_live_updates")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const timeout = config.get("disable_live_updates_timeout");
|
||||
if (timeout > 0) {
|
||||
// If one of these is available, use it to determine the time since the
|
||||
// last comment.
|
||||
const lastCommentedAt = story.lastCommentedAt || story.createdAt;
|
||||
|
||||
// If this date is before the timeout...
|
||||
if (
|
||||
DateTime.fromJSDate(lastCommentedAt)
|
||||
.plus({
|
||||
milliseconds: timeout,
|
||||
})
|
||||
.toJSDate() <= now
|
||||
) {
|
||||
// Then we know that the last comment (or lack there of) was left more
|
||||
// than the timeout specified in configuration.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// If the story doesn't specify the enabled property...
|
||||
if (story.settings.live?.enabled === undefined) {
|
||||
// Default to the tenant live setting!
|
||||
return tenant.live.enabled;
|
||||
}
|
||||
|
||||
return story.settings.live?.enabled;
|
||||
}
|
||||
|
||||
@@ -51,6 +51,11 @@ comment-count-text =
|
||||
comments-allCommentsTab = All Comments
|
||||
comments-featuredTab = Featured
|
||||
comments-counter-shortNum = { SHORT_NUMBER($count) }
|
||||
comments-watchers = { SHORT_NUMBER($count) } {
|
||||
$count ->
|
||||
[one] person is online
|
||||
*[other] people are online
|
||||
}
|
||||
comments-featuredCommentTooltip-how = How is a comment featured?
|
||||
comments-featuredCommentTooltip-handSelectedComments =
|
||||
Comments are chosen by our team as worth reading.
|
||||
|
||||
Reference in New Issue
Block a user