From 3d9f8bd9eada309de65fe8412b06c406c12e3084 Mon Sep 17 00:00:00 2001
From: Wyatt Johnson
Date: Fri, 7 Aug 2020 18:12:13 -0600
Subject: [PATCH] feat: implmentation with Redis
---
.../client/admin/routes/Stories/StoryRow.css | 8 +
.../client/admin/routes/Stories/StoryRow.tsx | 20 +-
.../routes/Stories/StoryRowContainer.tsx | 2 +
src/core/client/framework/hooks/index.ts | 1 +
.../framework/hooks/useVisibilityState.ts | 40 ++++
.../Stream/RefreshStoryViewerCount.ts | 33 ++++
.../Stream/ViewersWatchingContainer.tsx | 78 +++++++-
src/core/server/config.ts | 2 +-
src/core/server/events/events.ts | 10 -
src/core/server/events/listeners/viewers.ts | 24 ++-
.../graph/resolvers/LiveConfiguration.ts | 51 +----
.../LiveStoryViewersUpdatePayload.ts | 4 -
src/core/server/graph/resolvers/Story.ts | 41 ++--
.../server/graph/resolvers/StorySettings.ts | 15 +-
.../resolvers/Subscription/commentCreated.ts | 1 +
.../Subscription/commentReplyCreated.ts | 1 +
.../graph/resolvers/Subscription/index.ts | 3 -
.../Subscription/liveStoryViewersUpdate.ts | 31 ----
.../graph/resolvers/Subscription/types.ts | 5 +-
src/core/server/graph/resolvers/index.ts | 2 -
src/core/server/graph/schema/schema.graphql | 24 +--
src/core/server/graph/subscriptions/server.ts | 54 +++++-
src/core/server/models/story/viewers.ts | 175 +++++++++++-------
src/core/server/services/events/comments.ts | 6 +-
src/core/server/services/migrate/indexing.ts | 1 -
.../migrations/1596782282871_story_viewers.ts | 20 --
.../server/services/mongodb/collections.ts | 4 -
src/core/server/services/stories/index.ts | 40 ++++
src/core/server/services/stories/viewers.ts | 13 --
src/locales/en-US/stream.ftl | 4 +-
30 files changed, 439 insertions(+), 274 deletions(-)
create mode 100644 src/core/client/framework/hooks/useVisibilityState.ts
create mode 100644 src/core/client/stream/tabs/Comments/Stream/RefreshStoryViewerCount.ts
delete mode 100644 src/core/server/graph/resolvers/LiveStoryViewersUpdatePayload.ts
delete mode 100644 src/core/server/graph/resolvers/Subscription/liveStoryViewersUpdate.ts
delete mode 100644 src/core/server/services/migrate/migrations/1596782282871_story_viewers.ts
delete mode 100644 src/core/server/services/stories/viewers.ts
diff --git a/src/core/client/admin/routes/Stories/StoryRow.css b/src/core/client/admin/routes/Stories/StoryRow.css
index efeb3a438..15427826a 100644
--- a/src/core/client/admin/routes/Stories/StoryRow.css
+++ b/src/core/client/admin/routes/Stories/StoryRow.css
@@ -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,
diff --git a/src/core/client/admin/routes/Stories/StoryRow.tsx b/src/core/client/admin/routes/Stories/StoryRow.tsx
index b9c27ecae..a4a7c57ce 100644
--- a/src/core/client/admin/routes/Stories/StoryRow.tsx
+++ b/src/core/client/admin/routes/Stories/StoryRow.tsx
@@ -32,6 +32,7 @@ interface Props {
reportedCount: number | null;
pendingCount: number | null;
totalCount: number;
+ viewerCount: number | null;
}
const UserRow: FunctionComponent = (props) => (
@@ -50,10 +51,23 @@ const UserRow: FunctionComponent = (props) => (
props.title ||
)}
- {(props.author || props.publishDate) && (
+ {(props.author || props.publishDate || !!props.viewerCount) && (
- {props.author}{" "}
- {props.publishDate}
+ {!!props.author && (
+
+ {props.author}
+
+ )}
+
+ {!!props.publishDate && (
+ {props.publishDate}
+ )}
+
+ {!!props.viewerCount && (
+
+ {props.viewerCount} reading now
+
+ )}
)}
diff --git a/src/core/client/admin/routes/Stories/StoryRowContainer.tsx b/src/core/client/admin/routes/Stories/StoryRowContainer.tsx
index 5a3094ee7..d8052ac1a 100644
--- a/src/core/client/admin/routes/Stories/StoryRowContainer.tsx
+++ b/src/core/client/admin/routes/Stories/StoryRowContainer.tsx
@@ -53,6 +53,7 @@ const StoryRowContainer: FunctionComponent = (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({
count
}
}
+ viewerCount
site {
name
id
diff --git a/src/core/client/framework/hooks/index.ts b/src/core/client/framework/hooks/index.ts
index 92b69f39b..d78a68ae4 100644
--- a/src/core/client/framework/hooks/index.ts
+++ b/src/core/client/framework/hooks/index.ts
@@ -8,3 +8,4 @@ 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";
diff --git a/src/core/client/framework/hooks/useVisibilityState.ts b/src/core/client/framework/hooks/useVisibilityState.ts
new file mode 100644
index 000000000..a52c90508
--- /dev/null
+++ b/src/core/client/framework/hooks/useVisibilityState.ts
@@ -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;
diff --git a/src/core/client/stream/tabs/Comments/Stream/RefreshStoryViewerCount.ts b/src/core/client/stream/tabs/Comments/Stream/RefreshStoryViewerCount.ts
new file mode 100644
index 000000000..4c6c90788
--- /dev/null
+++ b/src/core/client/stream/tabs/Comments/Stream/RefreshStoryViewerCount.ts
@@ -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) => {
+ return fetchQuery(
+ environment,
+ graphql`
+ query RefreshStoryViewerCountQuery($storyID: ID!) {
+ settings {
+ ...ViewersWatchingContainer_settings
+ }
+ story(id: $storyID) {
+ ...ViewersWatchingContainer_story
+ }
+ }
+ `,
+ variables,
+ { force: true }
+ );
+ }
+);
+
+export default RefreshStoryViewerCount;
diff --git a/src/core/client/stream/tabs/Comments/Stream/ViewersWatchingContainer.tsx b/src/core/client/stream/tabs/Comments/Stream/ViewersWatchingContainer.tsx
index 116ce9b85..c6c1d5387 100644
--- a/src/core/client/stream/tabs/Comments/Stream/ViewersWatchingContainer.tsx
+++ b/src/core/client/stream/tabs/Comments/Stream/ViewersWatchingContainer.tsx
@@ -1,15 +1,17 @@
import { Localized } from "@fluent/react/compat";
-import React, { FunctionComponent } from "react";
+import React, { FunctionComponent, useEffect, useState } from "react";
import { graphql } from "react-relay";
-import { useLive } from "coral-framework/hooks";
-import { withFragmentContainer } from "coral-framework/lib/relay";
+import { useLive, useVisibilityState } from "coral-framework/hooks";
+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 {
@@ -17,17 +19,80 @@ interface Props {
settings: ViewersWatchingContainer_settings;
}
+const TIMEOUT = 20000;
+const JITTER = 10000;
+function getTimeout() {
+ return TIMEOUT + Math.floor(Math.random() * JITTER);
+}
+
const ViewersWatchingContainer: FunctionComponent = ({
story,
settings,
}) => {
+ const [refreshed, setRefreshed] = useState(false);
const live = useLive({ story, settings });
- if (!live) {
+ const visible = useVisibilityState();
+ const refreshStoryViewerCount = useFetch(RefreshStoryViewerCount);
+
+ // available will be true when the viewer count is available.
+ const available = story.viewerCount !== null;
+
+ useEffect(() => {
+ // If we aren't live, we can't refresh the count cause there can't be any!
+ if (!live || !visible || !available) {
+ return;
+ }
+
+ // Setup a timeout to be re-used.
+ let timeout: number | null = null;
+ let disposed = false;
+
+ // Create a function that can be used to refresh the story viewer count.
+ const refresh = async () => {
+ try {
+ // Refresh the viewer count!
+ await refreshStoryViewerCount({ storyID: story.id });
+
+ // If we're disposed, then stop now!
+ if (disposed) {
+ return;
+ }
+
+ // Mark that we've refreshed (so we remove the extra +1).
+ setRefreshed(true);
+
+ // Add this back with a timeout if we aren't disposed.
+ timeout = window.setTimeout(refresh, getTimeout());
+ } catch (err) {
+ if (process.env.NODE_ENV !== "production") {
+ // eslint-disable-next-line no-console
+ console.error("couldn not refresh the story viewer count:", err);
+ }
+ }
+ };
+
+ // Configure a timeout to fire later to refresh.
+ timeout = window.setTimeout(refresh, getTimeout());
+
+ // Clear the timeout when we dispose.
+ return () => {
+ // Clear the timeout if it's still running.
+ if (timeout) {
+ window.clearTimeout(timeout);
+ }
+
+ // Mark this as disposed so we don't update state after a refresh.
+ disposed = true;
+ };
+ }, [live, story.id, refreshStoryViewerCount, visible, available]);
+
+ // 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 = story.viewerCount + 1;
+ const viewerCount = refreshed ? story.viewerCount : story.viewerCount + 1;
return (
= ({
icon={play_circle_filled}
title={
}
titleWeight="semiBold"
@@ -46,6 +111,7 @@ const ViewersWatchingContainer: FunctionComponent = ({
const enhanced = withFragmentContainer({
story: graphql`
fragment ViewersWatchingContainer_story on Story {
+ id
viewerCount
isClosed
settings {
diff --git a/src/core/server/config.ts b/src/core/server/config.ts
index a7832569e..a9d2db7ad 100644
--- a/src/core/server/config.ts
+++ b/src/core/server/config.ts
@@ -266,7 +266,7 @@ const config = convict({
doc:
"The length of time (in ms) that a user should be considered active on a story without interaction.",
format: "ms",
- default: ms("2 minutes"),
+ default: ms("15 minutes"),
env: "STORY_VIEWER_TIMEOUT",
},
force_ssl: {
diff --git a/src/core/server/events/events.ts b/src/core/server/events/events.ts
index e55816e72..dbe49aa33 100644
--- a/src/core/server/events/events.ts
+++ b/src/core/server/events/events.ts
@@ -6,7 +6,6 @@ import {
CommentReleasedInput,
CommentReplyCreatedInput,
CommentStatusUpdatedInput,
- LiveStoryViewersUpdateInput,
} from "coral-server/graph/resolvers/Subscription";
import { FLAG_REASON } from "coral-server/models/action/comment";
@@ -121,12 +120,3 @@ export type StoryCreatedCoralEventPayload = CoralEventPayload<
export const StoryCreatedCoralEvent = createCoralEvent<
StoryCreatedCoralEventPayload
>(CoralEventType.STORY_CREATED);
-
-export type LiveStoryViewersUpdateEventPayload = CoralEventPayload<
- CoralEventType.LIVE_STORY_VIEWERS_UPDATE,
- LiveStoryViewersUpdateInput
->;
-
-export const LiveStoryViewersUpdateEvent = createCoralEvent<
- LiveStoryViewersUpdateEventPayload
->(CoralEventType.LIVE_STORY_VIEWERS_UPDATE);
diff --git a/src/core/server/events/listeners/viewers.ts b/src/core/server/events/listeners/viewers.ts
index 50a213ede..2c56a3504 100644
--- a/src/core/server/events/listeners/viewers.ts
+++ b/src/core/server/events/listeners/viewers.ts
@@ -1,4 +1,7 @@
-import { touchStoryViewer } from "coral-server/models/story/viewers";
+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,
@@ -24,12 +27,25 @@ export class ViewersCoralEventListener
public initialize: CoralEventPublisherFactory<
ViewersCoralEventListenerPayloads
- > = ({ clientID, mongo, now }) => async () => {
+ > = ({ clientID, redis, tenant, config }) => async ({ data }) => {
if (!clientID) {
return;
}
- // NOTE: (wyattjoh) maybe replace this with the create instead?
- await touchStoryViewer(mongo, clientID, now);
+ // 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")
+ );
};
}
diff --git a/src/core/server/graph/resolvers/LiveConfiguration.ts b/src/core/server/graph/resolvers/LiveConfiguration.ts
index 2c7f7625e..a18f65b95 100644
--- a/src/core/server/graph/resolvers/LiveConfiguration.ts
+++ b/src/core/server/graph/resolvers/LiveConfiguration.ts
@@ -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 = {
- 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),
};
diff --git a/src/core/server/graph/resolvers/LiveStoryViewersUpdatePayload.ts b/src/core/server/graph/resolvers/LiveStoryViewersUpdatePayload.ts
deleted file mode 100644
index d88c4bfb5..000000000
--- a/src/core/server/graph/resolvers/LiveStoryViewersUpdatePayload.ts
+++ /dev/null
@@ -1,4 +0,0 @@
-import { GQLLiveStoryViewersUpdatePayloadTypeResolver } from "coral-server/graph/schema/__generated__/types";
-import { LiveStoryViewersUpdateInput } from "./Subscription";
-
-export const LiveStoryViewersUpdatePayload: GQLLiveStoryViewersUpdatePayloadTypeResolver = {};
diff --git a/src/core/server/graph/resolvers/Story.ts b/src/core/server/graph/resolvers/Story.ts
index 9c3c0d78a..8629950f0 100644
--- a/src/core/server/graph/resolvers/Story.ts
+++ b/src/core/server/graph/resolvers/Story.ts
@@ -8,6 +8,7 @@ import {
canModerate,
hasModeratorRole,
} from "coral-server/models/user/helpers";
+import { isStoryLiveEnabled } from "coral-server/services/stories";
import {
GQLFEATURE_FLAG,
@@ -50,24 +51,34 @@ export const Story: GQLStoryTypeResolver = {
// 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: (s, input, ctx) =>
- // TODO: (wyattjoh) return 0 when live updates are disabled
- countStoryViewers(
- ctx.mongo,
- ctx.tenant.id,
- s.siteID,
- s.id,
- ctx.config.get("story_viewer_timeout"),
- ctx.now
- ),
+ 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")
+ );
+ },
};
diff --git a/src/core/server/graph/resolvers/StorySettings.ts b/src/core/server/graph/resolvers/StorySettings.ts
index 03a2c152d..a74ceac00 100644
--- a/src/core/server/graph/resolvers/StorySettings.ts
+++ b/src/core/server/graph/resolvers/StorySettings.ts
@@ -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 = {
- 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> = {
+ live: (s): LiveConfigurationInput => s.story,
moderation: (s, input, ctx) => s.moderation || ctx.tenant.moderation,
premodLinksEnable: (s, input, ctx) =>
s.premodLinksEnable || ctx.tenant.premodLinksEnable,
diff --git a/src/core/server/graph/resolvers/Subscription/commentCreated.ts b/src/core/server/graph/resolvers/Subscription/commentCreated.ts
index 98c32de65..060ae5680 100644
--- a/src/core/server/graph/resolvers/Subscription/commentCreated.ts
+++ b/src/core/server/graph/resolvers/Subscription/commentCreated.ts
@@ -9,6 +9,7 @@ import {
export interface CommentCreatedInput extends SubscriptionPayload {
storyID: string;
+ siteID: string;
commentID: string;
}
diff --git a/src/core/server/graph/resolvers/Subscription/commentReplyCreated.ts b/src/core/server/graph/resolvers/Subscription/commentReplyCreated.ts
index a851a4ab4..a7a81d972 100644
--- a/src/core/server/graph/resolvers/Subscription/commentReplyCreated.ts
+++ b/src/core/server/graph/resolvers/Subscription/commentReplyCreated.ts
@@ -11,6 +11,7 @@ export interface CommentReplyCreatedInput extends SubscriptionPayload {
ancestorIDs: string[];
commentID: string;
storyID: string;
+ siteID: string;
}
export type CommentReplyCreatedSubscription = SubscriptionType<
diff --git a/src/core/server/graph/resolvers/Subscription/index.ts b/src/core/server/graph/resolvers/Subscription/index.ts
index 58583f85b..70e4f6cbf 100644
--- a/src/core/server/graph/resolvers/Subscription/index.ts
+++ b/src/core/server/graph/resolvers/Subscription/index.ts
@@ -7,7 +7,6 @@ import { commentLeftModerationQueue } from "./commentLeftModerationQueue";
import { commentReleased } from "./commentReleased";
import { commentReplyCreated } from "./commentReplyCreated";
import { commentStatusUpdated } from "./commentStatusUpdated";
-import { liveStoryViewersUpdate } from "./liveStoryViewersUpdate";
export const Subscription: Required = {
commentCreated,
@@ -17,7 +16,6 @@ export const Subscription: Required = {
commentStatusUpdated,
commentFeatured,
commentReleased,
- liveStoryViewersUpdate,
};
export { CommentFeaturedInput } from "./commentFeatured";
@@ -27,4 +25,3 @@ export { CommentLeftModerationQueueInput } from "./commentLeftModerationQueue";
export { CommentReleasedInput } from "./commentReleased";
export { CommentReplyCreatedInput } from "./commentReplyCreated";
export { CommentStatusUpdatedInput } from "./commentStatusUpdated";
-export { LiveStoryViewersUpdateInput } from "./liveStoryViewersUpdate";
diff --git a/src/core/server/graph/resolvers/Subscription/liveStoryViewersUpdate.ts b/src/core/server/graph/resolvers/Subscription/liveStoryViewersUpdate.ts
deleted file mode 100644
index 861d88456..000000000
--- a/src/core/server/graph/resolvers/Subscription/liveStoryViewersUpdate.ts
+++ /dev/null
@@ -1,31 +0,0 @@
-import { SubscriptionToLiveStoryViewersUpdateResolver } from "coral-server/graph/schema/__generated__/types";
-
-import { createIterator } from "./helpers";
-import {
- SUBSCRIPTION_CHANNELS,
- SubscriptionPayload,
- SubscriptionType,
-} from "./types";
-
-export interface LiveStoryViewersUpdateInput extends SubscriptionPayload {
- viewerCount: number;
- storyID: string;
-}
-
-export type LiveStoryViewersUpdateSubscription = SubscriptionType<
- SUBSCRIPTION_CHANNELS.LIVE_STORY_VIEWERS_UPDATE,
- LiveStoryViewersUpdateInput
->;
-
-export const liveStoryViewersUpdate: SubscriptionToLiveStoryViewersUpdateResolver = createIterator(
- SUBSCRIPTION_CHANNELS.LIVE_STORY_VIEWERS_UPDATE,
- {
- filter: (source, { storyID }) => {
- if (source.storyID !== storyID) {
- return false;
- }
-
- return true;
- },
- }
-);
diff --git a/src/core/server/graph/resolvers/Subscription/types.ts b/src/core/server/graph/resolvers/Subscription/types.ts
index 5e750883f..72c44904d 100644
--- a/src/core/server/graph/resolvers/Subscription/types.ts
+++ b/src/core/server/graph/resolvers/Subscription/types.ts
@@ -5,7 +5,6 @@ import { CommentLeftModerationQueueSubscription } from "./commentLeftModerationQ
import { CommentReleasedSubscription } from "./commentReleased";
import { CommentReplyCreatedSubscription } from "./commentReplyCreated";
import { CommentStatusUpdatedSubscription } from "./commentStatusUpdated";
-import { LiveStoryViewersUpdateSubscription } from "./liveStoryViewersUpdate";
export enum SUBSCRIPTION_CHANNELS {
COMMENT_ENTERED_MODERATION_QUEUE = "COMMENT_ENTERED_MODERATION_QUEUE",
@@ -15,7 +14,6 @@ export enum SUBSCRIPTION_CHANNELS {
COMMENT_CREATED = "COMMENT_CREATED",
COMMENT_FEATURED = "COMMENT_FEATURED",
COMMENT_RELEASED = "COMMENT_RELEASED",
- LIVE_STORY_VIEWERS_UPDATE = "LIVE_STORY_VIEWERS_UPDATE",
}
export interface SubscriptionPayload {
@@ -37,5 +35,4 @@ export type SUBSCRIPTION_INPUT =
| CommentReplyCreatedSubscription
| CommentCreatedSubscription
| CommentFeaturedSubscription
- | CommentReleasedSubscription
- | LiveStoryViewersUpdateSubscription;
+ | CommentReleasedSubscription;
diff --git a/src/core/server/graph/resolvers/index.ts b/src/core/server/graph/resolvers/index.ts
index 84141da32..4b3d78582 100644
--- a/src/core/server/graph/resolvers/index.ts
+++ b/src/core/server/graph/resolvers/index.ts
@@ -29,7 +29,6 @@ import { GiphyMediaConfiguration } from "./GiphyMediaConfiguration";
import { GoogleAuthIntegration } from "./GoogleAuthIntegration";
import { Invite } from "./Invite";
import { LiveConfiguration } from "./LiveConfiguration";
-import { LiveStoryViewersUpdatePayload } from "./LiveStoryViewersUpdatePayload";
import { MediaConfiguration } from "./MediaConfiguration";
import { ModerationQueue } from "./ModerationQueue";
import { ModerationQueues } from "./ModerationQueues";
@@ -93,7 +92,6 @@ const Resolvers: GQLResolver = {
GoogleAuthIntegration,
Invite,
LiveConfiguration,
- LiveStoryViewersUpdatePayload,
Locale,
MediaConfiguration,
ModerationQueue,
diff --git a/src/core/server/graph/schema/schema.graphql b/src/core/server/graph/schema/schema.graphql
index 15a9dae20..d2f956719 100644
--- a/src/core/server/graph/schema/schema.graphql
+++ b/src/core/server/graph/schema/schema.graphql
@@ -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.
@@ -3452,7 +3458,7 @@ type Story {
"""
viewerCount is the number of viewers active on this Story.
"""
- viewerCount: Int!
+ viewerCount: Int
}
"""
@@ -7970,17 +7976,6 @@ type CommentReleasedPayload {
comment: Comment!
}
-"""
-LiveStoryViewersUpdatePayload is returned when the viewer count on a story has
-been updated.
-"""
-type LiveStoryViewersUpdatePayload {
- """
- viewerCount is the number of viewers on the selected story.
- """
- viewerCount: Int!
-}
-
type Subscription {
"""
commentEnteredModerationQueue returns when a Comment enters a ModerationQueue.
@@ -8033,9 +8028,4 @@ type Subscription {
"""
commentFeatured(storyID: ID!): CommentFeaturedPayload!
@auth(roles: [MODERATOR, ADMIN])
-
- """
- liveStoryViewersUpdate returns when the number of viewers on a story changes.
- """
- liveStoryViewersUpdate(storyID: ID!): LiveStoryViewersUpdatePayload!
}
diff --git a/src/core/server/graph/subscriptions/server.ts b/src/core/server/graph/subscriptions/server.ts
index 7245eaa0f..b4bce940e 100644
--- a/src/core/server/graph/subscriptions/server.ts
+++ b/src/core/server/graph/subscriptions/server.ts
@@ -43,11 +43,15 @@ 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";
@@ -110,8 +114,24 @@ export function extractBundleConfig(
return null;
}
-function hasClientID(socket: any): socket is { clientID: string } {
- if (typeof socket.clientID === "string" && socket.clientID.length > 0) {
+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;
}
@@ -194,8 +214,13 @@ export function onConnect(options: OnConnectOptions): OnConnectFn {
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 the request has a clientID...
+ // If this tenant has this feature flag enabled...
+ enabled &&
+ // And the request has a clientID...
clientID &&
// And it's from the stream...
bundleID === "stream" &&
@@ -204,8 +229,6 @@ export function onConnect(options: OnConnectOptions): OnConnectFn {
// And we have either a storyID or storyURL on the config...
(bundleConfig.storyID || bundleConfig.storyURL)
) {
- // TODO: (wyattjoh) validate the bundle config.
-
// Then we need to create a new storyViewerf for the request!
const story = await find(options.mongo, tenant, {
id: bundleConfig.storyID,
@@ -214,17 +237,21 @@ export function onConnect(options: OnConnectOptions): OnConnectFn {
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.mongo,
+ options.redis,
{
tenantID: tenant.id,
siteID: story.siteID,
storyID: story.id,
- clientID,
},
+ clientID,
+ options.config.get("story_viewer_timeout"),
ctx.now
);
}
@@ -265,8 +292,15 @@ function onDisconnect(options: OnDisconnectOptions): OnDisconnectFn {
// If the socket has a clientID attached, then remove the story viewer
// entry.
- if (hasClientID(socket)) {
- await removeStoryViewer(options.mongo, socket.clientID);
+ if (hasStoryViewer(socket)) {
+ const { tenantID, siteID, storyID } = socket;
+
+ await removeStoryViewer(
+ options.redis,
+ { tenantID, siteID, storyID },
+ socket.clientID,
+ options.config.get("story_viewer_timeout")
+ );
}
};
}
diff --git a/src/core/server/models/story/viewers.ts b/src/core/server/models/story/viewers.ts
index 7c70f5ffd..e5020e6dc 100644
--- a/src/core/server/models/story/viewers.ts
+++ b/src/core/server/models/story/viewers.ts
@@ -1,86 +1,127 @@
-import { DateTime } from "luxon";
-import { Db } from "mongodb";
+import { Redis } from "ioredis";
-import { storyViewer as collection } from "coral-server/services/mongodb/collections";
+import { createTimer } from "coral-server/helpers";
+import logger from "coral-server/logger";
-import { TenantResource } from "../tenant";
-
-export interface StoryViewer extends TenantResource {
- storyID: string;
+interface KeySpec {
+ tenantID: string;
siteID: string;
- clientID: string;
- lastInteractedAt: Date;
+ storyID: string;
}
-interface CreateStoryViewer {
- storyID: string;
- siteID: string;
- tenantID: string;
- clientID: 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(
- mongo: Db,
- { clientID, ...input }: CreateStoryViewer,
- now: Date
-) {
- await collection(mongo).findOneAndUpdate(
- { clientID },
- {
- $set: {
- ...input,
- lastInteractedAt: now,
- },
- },
- {
- upsert: true,
- }
- );
+ redis: Redis,
+ spec: KeySpec,
+ clientID: string,
+ precision: number,
+ now = new Date()
+): Promise {
+ 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(mongo: Db, clientID: string) {
- await collection(mongo).deleteOne({ clientID });
-}
+export async function removeStoryViewer(
+ redis: Redis,
+ spec: KeySpec,
+ clientID: string,
+ precision: number,
+ now = new Date()
+): Promise {
+ const timer = createTimer();
-export async function touchStoryViewer(mongo: Db, clientID: string, now: Date) {
- await collection(mongo).updateOne(
- { clientID },
- { $set: { lastInteractedAt: now } }
- );
+ // 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(
- mongo: Db,
- tenantID: string,
- siteID: string,
- storyID: string,
- timeout: number,
- now: Date
+ redis: Redis,
+ spec: KeySpec,
+ precision: number,
+ now = new Date()
) {
- const start = DateTime.fromJSDate(now).minus({ second: timeout }).toJSDate();
+ const timer = createTimer();
- const results = await collection<{ count: number }>(mongo)
- .aggregate([
- {
- $match: {
- tenantID,
- siteID,
- storyID,
- lastInteractedAt: { $gt: start, $lte: now },
- },
- },
- {
- $group: {
- _id: null,
- count: { $sum: 1 },
- },
- },
- ])
- .toArray();
+ // Compute the key for this entry.
+ const key = calculateReadKey(spec, precision, now);
- if (results.length === 0) {
- return 0;
- }
+ // Count the number of clientID's.
+ const count = await redis.scard(key);
- return results[0].count;
+ logger.info({ took: timer(), count, spec }, "counted story viewers");
+
+ return count;
}
diff --git a/src/core/server/services/events/comments.ts b/src/core/server/services/events/comments.ts
index 0cbcd7476..379a2fe7e 100644
--- a/src/core/server/services/events/comments.ts
+++ b/src/core/server/services/events/comments.ts
@@ -46,25 +46,27 @@ export async function publishCommentStatusChanges(
export async function publishCommentReplyCreated(
broker: CoralEventPublisherBroker,
- comment: Pick
+ comment: Pick
) {
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: Pick
) {
if (!comment.parentID && hasPublishedStatus(comment)) {
await CommentCreatedCoralEvent.publish(broker, {
commentID: comment.id,
storyID: comment.storyID,
+ siteID: comment.siteID,
});
}
}
diff --git a/src/core/server/services/migrate/indexing.ts b/src/core/server/services/migrate/indexing.ts
index e7bd4f1d4..4f5c9a504 100644
--- a/src/core/server/services/migrate/indexing.ts
+++ b/src/core/server/services/migrate/indexing.ts
@@ -95,5 +95,4 @@ export const createIndexesFactory = (mongo: Db) => ({
queries: createIndexFactory(collections.queries(mongo)),
migrations: createIndexFactory(collections.migrations(mongo)),
sites: createIndexFactory(collections.sites(mongo)),
- storyViewer: createIndexFactory(collections.storyViewer(mongo)),
});
diff --git a/src/core/server/services/migrate/migrations/1596782282871_story_viewers.ts b/src/core/server/services/migrate/migrations/1596782282871_story_viewers.ts
deleted file mode 100644
index cb6067c25..000000000
--- a/src/core/server/services/migrate/migrations/1596782282871_story_viewers.ts
+++ /dev/null
@@ -1,20 +0,0 @@
-import { Db } from "mongodb";
-
-import Migration from "coral-server/services/migrate/migration";
-import collections from "coral-server/services/mongodb/collections";
-
-import { createIndexFactory } from "../indexing";
-
-export default class extends Migration {
- // Remove the following line once the migration is ready, otherwise the
- // migration will not be ran!
- public static disabled = true;
-
- public async indexes(mongo: Db) {
- const indexer = createIndexFactory(collections.storyViewer(mongo));
-
- await indexer({ storyID: 1 });
- await indexer({ clientID: 1 }, { unique: true });
- await indexer({ lastInteractedAt: 1 }, { expireAfterSeconds: 30 * 60 });
- }
-}
diff --git a/src/core/server/services/mongodb/collections.ts b/src/core/server/services/mongodb/collections.ts
index 00098eaad..4890f2824 100644
--- a/src/core/server/services/mongodb/collections.ts
+++ b/src/core/server/services/mongodb/collections.ts
@@ -7,7 +7,6 @@ import { MigrationRecord } from "coral-server/models/migration";
import { PersistedQuery } from "coral-server/models/queries";
import { Site } from "coral-server/models/site";
import { Story } from "coral-server/models/story";
-import { StoryViewer } from "coral-server/models/story/viewers";
import { Tenant } from "coral-server/models/tenant";
import { User } from "coral-server/models/user";
@@ -33,8 +32,6 @@ export const queries = createCollection("queries");
export const migrations = createCollection("migrations");
-export const storyViewer = createCollection("storyViewers");
-
const collections = {
users,
invites,
@@ -46,7 +43,6 @@ const collections = {
queries,
migrations,
sites,
- storyViewer,
};
export default collections;
diff --git a/src/core/server/services/stories/index.ts b/src/core/server/services/stories/index.ts
index eacd04907..bcab131ab 100644
--- a/src/core/server/services/stories/index.ts
+++ b/src/core/server/services/stories/index.ts
@@ -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;
+}
diff --git a/src/core/server/services/stories/viewers.ts b/src/core/server/services/stories/viewers.ts
deleted file mode 100644
index 9c5e522c9..000000000
--- a/src/core/server/services/stories/viewers.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-import { LiveStoryViewersUpdateEvent } from "coral-server/events";
-import { CoralEventPublisherBroker } from "coral-server/events/publisher";
-
-export async function publishViewersUpdate(
- broker: CoralEventPublisherBroker,
- storyID: string,
- viewerCount: number
-) {
- void LiveStoryViewersUpdateEvent.publish(broker, {
- storyID,
- viewerCount,
- });
-}
diff --git a/src/locales/en-US/stream.ftl b/src/locales/en-US/stream.ftl
index c30cec595..bb5f13dcd 100644
--- a/src/locales/en-US/stream.ftl
+++ b/src/locales/en-US/stream.ftl
@@ -53,8 +53,8 @@ comments-featuredTab = Featured
comments-counter-shortNum = { SHORT_NUMBER($count) }
comments-watchers = { SHORT_NUMBER($count) } {
$count ->
- [one] person is here
- *[other] people are here
+ [one] person is online
+ *[other] people are online
}
comments-featuredCommentTooltip-how = How is a comment featured?
comments-featuredCommentTooltip-handSelectedComments =