mirror of
https://github.com/wassname/talk.git
synced 2026-07-13 17:45:56 +08:00
feat: implmentation with Redis
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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> = {};
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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!
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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 =
|
||||
|
||||
Reference in New Issue
Block a user