mirror of
https://github.com/wassname/talk.git
synced 2026-07-22 13:00:29 +08:00
[CORL-166] Live Updates on Mod Queues (#2368)
* feat: client implementation of subscriptions and modqueue live counts * fix: unit tests * feat: live status update in moderation * feat: live update of new comments in moderation * chore: View New instead of View More * feat: fade in transition for new comments * chore: turn websocket proxy back on * feat: initial server impl * fix: make it work :-) * fix: add box shadow * chore: make test subscriptions only support 1 top level field following the spec * fix: linting * feat: support clientID * fix: linting * feat: support commentStatusUpdated subscription * fix: disabled styles for approve and reject button * feat: show moderated by system and update flags * feat: support metrics recording on websocket connections * fix: handle when same comment enters but leaves again
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { graphql, GraphQLSchema } from "graphql";
|
||||
import { graphql, GraphQLSchema, parse } from "graphql";
|
||||
import { IResolvers } from "graphql-tools";
|
||||
|
||||
import { SubscribeFunction } from "react-relay-network-modern/es";
|
||||
import {
|
||||
commitLocalUpdate,
|
||||
Environment,
|
||||
@@ -24,6 +24,8 @@ import {
|
||||
ModerationNudgeError,
|
||||
} from "coral-framework/lib/errors";
|
||||
|
||||
import { SubscriptionHandler } from "./createSubscriptionHandler";
|
||||
|
||||
export interface CreateRelayEnvironmentNetworkParams {
|
||||
/** project name of graphql-config */
|
||||
projectName: string;
|
||||
@@ -33,6 +35,8 @@ export interface CreateRelayEnvironmentNetworkParams {
|
||||
logNetwork?: boolean;
|
||||
/** If enabled, graphql errors will be muted */
|
||||
muteNetworkErrors?: boolean;
|
||||
/** handler for subscriptions */
|
||||
subscriptionHandler?: SubscriptionHandler;
|
||||
}
|
||||
|
||||
export interface CreateRelayEnvironmentParams {
|
||||
@@ -89,6 +93,55 @@ function createFetch({
|
||||
};
|
||||
}
|
||||
|
||||
function resolveArguments(
|
||||
variables: Record<string, any> = {},
|
||||
args: any[] = []
|
||||
) {
|
||||
return args.reduce((res, a) => {
|
||||
const argName = a.name.value;
|
||||
const variableName = a.value.name.value;
|
||||
if (variableName in variables) {
|
||||
res[argName] = variables[variableName];
|
||||
}
|
||||
return res;
|
||||
}, {});
|
||||
}
|
||||
|
||||
function createSubscribe(
|
||||
subscriptionHandler: SubscriptionHandler
|
||||
): SubscribeFunction {
|
||||
const fn: SubscribeFunction = (
|
||||
operation,
|
||||
variables,
|
||||
cacheConfig,
|
||||
observer
|
||||
) => {
|
||||
// TODO: (cvle) This could probably made less brittle to changes in the order of the
|
||||
// document AST.
|
||||
const subscriptionSelections = (parse(operation.text!) as any)
|
||||
.definitions[0].selectionSet.selections as any[];
|
||||
const sel = subscriptionSelections[0];
|
||||
const subscription = {
|
||||
field: sel.name.value,
|
||||
variables: resolveArguments(variables, sel.arguments),
|
||||
dispatch: (response: any) => {
|
||||
observer.onNext({
|
||||
data: {
|
||||
[sel.name.value]: response,
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
subscriptionHandler.add(subscription);
|
||||
return {
|
||||
dispose: () => {
|
||||
subscriptionHandler.remove(subscription);
|
||||
},
|
||||
};
|
||||
};
|
||||
return fn;
|
||||
}
|
||||
|
||||
/**
|
||||
* create Relay environment for tests environments.
|
||||
*/
|
||||
@@ -106,7 +159,10 @@ export default function createRelayEnvironment(
|
||||
wrapFetchWithLogger(createFetch({ schema }), {
|
||||
logResult: params.network.logNetwork,
|
||||
muteErrors: params.network.muteNetworkErrors,
|
||||
})
|
||||
}),
|
||||
params.network.subscriptionHandler
|
||||
? (createSubscribe(params.network.subscriptionHandler) as any)
|
||||
: undefined
|
||||
);
|
||||
}
|
||||
const environment = new Environment({
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { GQLSubscription } from "coral-framework/schema";
|
||||
|
||||
import { DeepPartial } from "coral-framework/types";
|
||||
|
||||
export type SubscriptionVariables<
|
||||
T extends SubscriptionResolver<any, any>
|
||||
> = T extends SubscriptionResolver<infer V, any> ? V : any;
|
||||
|
||||
export type SubscriptionResponse<
|
||||
T extends SubscriptionResolver<any, any>
|
||||
> = T extends SubscriptionResolver<any, infer R> ? DeepPartial<R> : any;
|
||||
|
||||
/**
|
||||
* SubscriptionResolver matches the shape of Subscription
|
||||
* resolvers in the schema generated types.
|
||||
*/
|
||||
export interface SubscriptionResolver<V, R> {
|
||||
resolve?: (parent: any, args: V, context: any, info: any) => R;
|
||||
}
|
||||
|
||||
type SubscriptionField = keyof GQLSubscription;
|
||||
|
||||
/**
|
||||
* Subscription represents a subscription currently requested from the client.
|
||||
*/
|
||||
export interface Subscription<T extends SubscriptionResolver<any, any> = any> {
|
||||
/** field of the subscription field being requested */
|
||||
field: SubscriptionField;
|
||||
/** variables of the subscription field being requested */
|
||||
variables: SubscriptionVariables<T>;
|
||||
/** dispatch data to this subscription */
|
||||
dispatch(data: SubscriptionResponse<T>): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* SubscriptionHandlerReadOnly enables to write tests with subscriptions.
|
||||
*/
|
||||
export interface SubscriptionHandlerReadOnly {
|
||||
/** List of current active subscriptions */
|
||||
readonly subscriptions: ReadonlyArray<Subscription>;
|
||||
/**
|
||||
* dispatch will look for subscriptions of the field `field` and
|
||||
* calls the `callback` for each of them. If `callback` returns data,
|
||||
* it'll be dispatched to that subscription.
|
||||
* @param field name of subscription field to look for.
|
||||
* @param callback callback is called for every subscription on this field.
|
||||
*/
|
||||
dispatch<T extends SubscriptionResolver<any, any> = any>(
|
||||
field: SubscriptionField,
|
||||
callback: (
|
||||
variables: SubscriptionVariables<T>
|
||||
) => SubscriptionResponse<T> | void
|
||||
): void;
|
||||
has(field: SubscriptionField): boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* SubscriptionHandler enables to write tests with subscriptions
|
||||
* and to manipulate the current list of subscriptions.
|
||||
*/
|
||||
export interface SubscriptionHandler extends SubscriptionHandlerReadOnly {
|
||||
add(subscription: Subscription): void;
|
||||
remove(subscription: Subscription): void;
|
||||
}
|
||||
|
||||
export default function createSubscriptionHandler(): SubscriptionHandler {
|
||||
const subscriptions: Subscription[] = [];
|
||||
const handler: SubscriptionHandler = {
|
||||
subscriptions,
|
||||
dispatch: (field, callback) => {
|
||||
subscriptions.forEach(s => {
|
||||
if (s.field === field) {
|
||||
const data = callback(s.variables as any);
|
||||
if (data) {
|
||||
s.dispatch(data);
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
has: field => subscriptions.some(s => s.field === field),
|
||||
add: s => {
|
||||
subscriptions.push(s);
|
||||
},
|
||||
remove: s => {
|
||||
const index = subscriptions.findIndex(x => x === s);
|
||||
if (index !== -1) {
|
||||
subscriptions.splice(index, 1);
|
||||
}
|
||||
},
|
||||
};
|
||||
return handler;
|
||||
}
|
||||
@@ -18,6 +18,9 @@ import { createUUIDGenerator } from "coral-framework/testHelpers";
|
||||
|
||||
import createFluentBundle from "./createFluentBundle";
|
||||
import createRelayEnvironment from "./createRelayEnvironment";
|
||||
import createSubscriptionHandler, {
|
||||
SubscriptionHandlerReadOnly,
|
||||
} from "./createSubscriptionHandler";
|
||||
|
||||
export type Resolver<V, R> = (
|
||||
parent: any,
|
||||
@@ -66,6 +69,7 @@ export default function createTestRenderer<
|
||||
element: React.ReactNode,
|
||||
params: CreateTestRendererParams<T>
|
||||
) {
|
||||
const subscriptionHandler = createSubscriptionHandler();
|
||||
const environment = createRelayEnvironment({
|
||||
network: {
|
||||
// Set this to true, to see graphql responses.
|
||||
@@ -73,6 +77,7 @@ export default function createTestRenderer<
|
||||
resolvers: params.resolvers as IResolvers<any, any>,
|
||||
muteNetworkErrors: params.muteNetworkErrors,
|
||||
projectName: "tenant",
|
||||
subscriptionHandler,
|
||||
},
|
||||
initLocalState: (localRecord, source, env) => {
|
||||
if (params.initLocalState) {
|
||||
@@ -111,5 +116,9 @@ export default function createTestRenderer<
|
||||
{ createNodeMock }
|
||||
);
|
||||
});
|
||||
return { context, testRenderer: testRenderer! };
|
||||
return {
|
||||
context,
|
||||
testRenderer: testRenderer!,
|
||||
subscriptionHandler: subscriptionHandler as SubscriptionHandlerReadOnly,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -11,25 +11,21 @@ export default function matchText(
|
||||
text: string,
|
||||
options: TextMatchOptions = {}
|
||||
) {
|
||||
if (typeof pattern === "string") {
|
||||
let a = text;
|
||||
let b = pattern;
|
||||
if (options.trim || options.trim === undefined) {
|
||||
a = a.trim();
|
||||
b = b.trim();
|
||||
}
|
||||
if (
|
||||
options.collapseWhitespace ||
|
||||
options.collapseWhitespace === undefined
|
||||
) {
|
||||
a = a.replace(/\s+/g, " ");
|
||||
b = b.replace(/\s+/g, " ");
|
||||
}
|
||||
|
||||
let a = pattern;
|
||||
let b = text;
|
||||
if (options.trim || options.trim === undefined) {
|
||||
a = typeof a === "string" ? a.trim() : a;
|
||||
b = b.trim();
|
||||
}
|
||||
if (options.collapseWhitespace || options.collapseWhitespace === undefined) {
|
||||
a = typeof a === "string" ? a.replace(/\s+/g, " ") : a;
|
||||
b = b.replace(/\s+/g, " ");
|
||||
}
|
||||
if (typeof a === "string") {
|
||||
if (options.exact || options.exact === undefined) {
|
||||
return a === b;
|
||||
}
|
||||
return text.toLowerCase().includes(pattern.toLowerCase());
|
||||
return b.toLowerCase().includes(a.toLowerCase());
|
||||
}
|
||||
return pattern.test(text);
|
||||
return a.test(b);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user