Compare commits

..
Author SHA1 Message Date
Wyatt Johnson 5acac741e4 fix: improve performance 2020-08-14 09:52:02 -06:00
Wyatt Johnson 8c6c8c1f72 fix: updated snaps 2020-08-14 09:52:01 -06:00
Wyatt Johnson 3d9f8bd9ea feat: implmentation with Redis 2020-08-14 09:52:01 -06:00
Wyatt Johnson 27d6f1c245 feat: initial impl 2020-08-14 09:52:00 -06:00
Wyatt Johnson f41623bbd4 feat: added useLive hook 2020-08-14 09:51:27 -06:00
43 changed files with 931 additions and 196 deletions
+4 -4
View File
@@ -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
View File
@@ -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",
+1
View File
@@ -19,6 +19,7 @@ async function main() {
const ManagedCoralContextProvider = await createManaged({
initLocalState,
localesData,
bundle: "account",
});
const Index: FunctionComponent = () => (
+1
View File
@@ -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>
+1
View File
@@ -19,6 +19,7 @@ async function main() {
const ManagedCoralContextProvider = await createManaged({
initLocalState,
localesData,
bundle: "auth",
});
const Index: FunctionComponent = () => (
+2
View File
@@ -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,
},
});
}
+1
View File
@@ -19,6 +19,7 @@ async function main() {
const ManagedCoralContextProvider = await createManaged({
localesData,
initLocalState,
bundle: "install",
});
const Index: FunctionComponent = () => (
+13
View File
@@ -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);
@@ -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);
@@ -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
View File
@@ -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.
+2 -2
View File
@@ -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 {
+7
View File
@@ -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")
);
};
}
+1
View File
@@ -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),
};
+27 -5
View File
@@ -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
}
"""
+148 -5
View File
@@ -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,
},
+2
View File
@@ -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());
+127
View File
@@ -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;
}
+4 -2
View File
@@ -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,
});
}
}
+40
View File
@@ -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;
}
+5
View File
@@ -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.