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={ - {viewerCount} people are here + {viewerCount} people is online } 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 =