feat: implmentation with Redis

This commit is contained in:
Wyatt Johnson
2020-08-07 18:12:13 -06:00
parent 27d6f1c245
commit 3d9f8bd9ea
30 changed files with 439 additions and 274 deletions
@@ -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
+1
View File
@@ -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";
@@ -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;
@@ -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;
@@ -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<Props> = ({
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 (
<CallOut
@@ -35,7 +100,7 @@ const ViewersWatchingContainer: FunctionComponent<Props> = ({
icon={<Icon size="md">play_circle_filled</Icon>}
title={
<Localized id="comments-watchers" $count={viewerCount}>
<span>{viewerCount} people are here</span>
<span>{viewerCount} people is online</span>
</Localized>
}
titleWeight="semiBold"
@@ -46,6 +111,7 @@ const ViewersWatchingContainer: FunctionComponent<Props> = ({
const enhanced = withFragmentContainer<Props>({
story: graphql`
fragment ViewersWatchingContainer_story on Story {
id
viewerCount
isClosed
settings {
+1 -1
View File
@@ -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: {
-10
View File
@@ -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);
+20 -4
View File
@@ -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")
);
};
}
@@ -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),
};
@@ -1,4 +0,0 @@
import { GQLLiveStoryViewersUpdatePayloadTypeResolver } from "coral-server/graph/schema/__generated__/types";
import { LiveStoryViewersUpdateInput } from "./Subscription";
export const LiveStoryViewersUpdatePayload: GQLLiveStoryViewersUpdatePayloadTypeResolver<LiveStoryViewersUpdateInput> = {};
+26 -15
View File
@@ -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<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: (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")
);
},
};
@@ -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<
@@ -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<GQLSubscriptionTypeResolver> = {
commentCreated,
@@ -17,7 +16,6 @@ export const Subscription: Required<GQLSubscriptionTypeResolver> = {
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";
@@ -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<LiveStoryViewersUpdateInput> = createIterator(
SUBSCRIPTION_CHANNELS.LIVE_STORY_VIEWERS_UPDATE,
{
filter: (source, { storyID }) => {
if (source.storyID !== storyID) {
return false;
}
return true;
},
}
);
@@ -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;
-2
View File
@@ -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,
+7 -17
View File
@@ -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!
}
+44 -10
View File
@@ -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")
);
}
};
}
+108 -67
View File
@@ -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<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(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<number> {
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;
}
+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,
});
}
}
@@ -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)),
});
@@ -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 });
}
}
@@ -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<PersistedQuery>("queries");
export const migrations = createCollection<MigrationRecord>("migrations");
export const storyViewer = createCollection<StoryViewer>("storyViewers");
const collections = {
users,
invites,
@@ -46,7 +43,6 @@ const collections = {
queries,
migrations,
sites,
storyViewer,
};
export default collections;
+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;
}
@@ -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,
});
}
+2 -2
View File
@@ -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 =