diff --git a/src/core/client/auth/index.tsx b/src/core/client/auth/index.tsx index b30487719..d6057f5a3 100644 --- a/src/core/client/auth/index.tsx +++ b/src/core/client/auth/index.tsx @@ -2,11 +2,7 @@ import React from "react"; import { StatelessComponent } from "react"; import ReactDOM from "react-dom"; -import { - createContext, - TalkContext, - TalkContextProvider, -} from "talk-framework/lib/bootstrap"; +import { createManaged } from "talk-framework/lib/bootstrap"; import AppContainer from "./containers/AppContainer"; import resizePopup from "./dom/resizePopup"; @@ -32,23 +28,17 @@ function pollPopupHeight(interval: number = 100) { }, interval); } -// This is called when the context is first initialized. -async function init({ relayEnvironment }: TalkContext) { - await initLocalState(relayEnvironment); -} - async function main() { - // Bootstrap our context. - const context = await createContext({ - init, + const ManagedTalkContextProvider = await createManaged({ + initLocalState, localesData, userLocales: navigator.languages, }); const Index: StatelessComponent = () => ( - + - + ); ReactDOM.render(, document.getElementById("app")); diff --git a/src/core/client/auth/test/create.tsx b/src/core/client/auth/test/create.tsx index a0b9d9da9..fc03cd354 100644 --- a/src/core/client/auth/test/create.tsx +++ b/src/core/client/auth/test/create.tsx @@ -1,4 +1,6 @@ +import { EventEmitter2 } from "eventemitter2"; import { IResolvers } from "graphql-tools"; +import { noop } from "lodash"; import React from "react"; import TestRenderer from "react-test-renderer"; import { Environment, RecordProxy, RecordSourceProxy } from "relay-runtime"; @@ -29,7 +31,6 @@ export default function create(params: CreateParams) { logNetwork: params.logNetwork, resolvers: params.resolvers, initLocalState: (localRecord, source, env) => { - localRecord.setValue(0, "authRevision"); if (params.initLocalState) { params.initLocalState(localRecord, source, env); } @@ -45,6 +46,8 @@ export default function create(params: CreateParams) { postMessage: new PostMessageService(), browserInfo: { ios: false }, uuidGenerator: createUUIDGenerator(), + eventEmitter: new EventEmitter2({ wildcard: true, maxListeners: 20 }), + clearSession: noop, }; const testRenderer = TestRenderer.create( diff --git a/src/core/client/framework/helpers/getMe.ts b/src/core/client/framework/helpers/getMe.ts index fb5bafcf9..d989b5be2 100644 --- a/src/core/client/framework/helpers/getMe.ts +++ b/src/core/client/framework/helpers/getMe.ts @@ -3,12 +3,9 @@ import { Environment, ROOT_ID } from "relay-runtime"; export default function getMe(environment: Environment) { const source = environment.getStore().getSource(); const root = source.get(ROOT_ID)!; - const meKey = Object.keys(root) - .reverse() - .find(s => s.startsWith("me("))!; - if (!root[meKey]) { + if (!root.me) { return null; } - const meID = root[meKey].__ref; + const meID = root.me.__ref; return source.get(meID)!; } diff --git a/src/core/client/framework/lib/bootstrap/TalkContext.tsx b/src/core/client/framework/lib/bootstrap/TalkContext.tsx index d855a884e..ece409674 100644 --- a/src/core/client/framework/lib/bootstrap/TalkContext.tsx +++ b/src/core/client/framework/lib/bootstrap/TalkContext.tsx @@ -1,3 +1,4 @@ +import { EventEmitter2 } from "eventemitter2"; import { LocalizationProvider } from "fluent-react/compat"; import { FluentBundle } from "fluent/compat"; import { Child as PymChild } from "pym.js"; @@ -52,6 +53,12 @@ export interface TalkContext { /** Generates uuids. */ uuidGenerator: () => string; + + /** A event emitter */ + eventEmitter: EventEmitter2; + + /** Clear session data. */ + clearSession: () => void; } const { Provider, Consumer } = React.createContext({} as any); diff --git a/src/core/client/framework/lib/bootstrap/createContext.tsx b/src/core/client/framework/lib/bootstrap/createContext.tsx deleted file mode 100644 index 839cee453..000000000 --- a/src/core/client/framework/lib/bootstrap/createContext.tsx +++ /dev/null @@ -1,147 +0,0 @@ -import { EventEmitter2 } from "eventemitter2"; -import { Localized } from "fluent-react/compat"; -import { noop } from "lodash"; -import { Child as PymChild } from "pym.js"; -import React from "react"; -import { Formatter } from "react-timeago"; -import { Environment, Network, RecordSource, Store } from "relay-runtime"; -import uuid from "uuid/v4"; - -import { getBrowserInfo } from "talk-framework/lib/browserInfo"; -import { LOCAL_ID } from "talk-framework/lib/relay"; -import { - createLocalStorage, - createPromisifiedStorage, - createPymStorage, - createSessionStorage, -} from "talk-framework/lib/storage"; - -import { RestClient } from "talk-framework/lib/rest"; -import { ClickFarAwayRegister } from "talk-ui/components/ClickOutside"; - -import { generateBundles, LocalesData, negotiateLanguages } from "../i18n"; -import { createFetch, TokenGetter } from "../network"; -import { PostMessageService } from "../postMessage"; -import { TalkContext } from "./TalkContext"; - -interface CreateContextArguments { - /** Locales that the user accepts, usually `navigator.languages`. */ - userLocales: ReadonlyArray; - - /** Locales data that is returned by our `locales-loader`. */ - localesData: LocalesData; - - /** Init will be called after the context has been created. */ - init?: ((context: TalkContext) => void | Promise); - - /** A pym child that interacts with the pym parent. */ - pym?: PymChild; - - /** Supports emitting and listening to events. */ - eventEmitter?: EventEmitter2; -} - -/** - * timeagoFormatter integrates timeago into our translation - * framework. It gets injected into the UIContext. - */ -export const timeagoFormatter: Formatter = (value, unit, suffix) => { - // We use 'in' instead of 'from now' for language consistency - const ourSuffix = suffix === "from now" ? "in" : suffix; - return ( - - now - - ); -}; - -function areWeInIframe() { - try { - return window.self !== window.top; - } catch (e) { - return true; - } -} - -/** - * `createContext` manages the dependencies of our framework - * and returns a `TalkContext` that can be passed to the - * `TalkContextProvider`. - */ -export default async function createContext({ - init = noop, - userLocales, - localesData, - pym, - eventEmitter = new EventEmitter2({ wildcard: true }), -}: CreateContextArguments): Promise { - const inIframe = areWeInIframe(); - // Initialize Relay. - const source = new RecordSource(); - const tokenGetter: TokenGetter = () => { - const localState = source.get(LOCAL_ID); - if (localState) { - return localState.authToken || ""; - } - return ""; - }; - const relayEnvironment = new Environment({ - network: Network.create(createFetch(tokenGetter)), - store: new Store(source), - }); - - // Listen for outside clicks. - let registerClickFarAway: ClickFarAwayRegister | undefined; - if (pym) { - registerClickFarAway = cb => { - pym.onMessage("click", cb); - // Return unlisten callback. - return () => { - const index = pym.messageHandlers.click.indexOf(cb); - if (index > -1) { - pym.messageHandlers.click.splice(index, 1); - } - }; - }; - } - - // Initialize i18n. - const locales = negotiateLanguages(userLocales, localesData); - - if (process.env.NODE_ENV !== "production") { - // tslint:disable:next-line: no-console - console.log(`Negotiated locales ${JSON.stringify(locales)}`); - } - - const localeBundles = await generateBundles(locales, localesData); - - // Assemble context. - const context = { - relayEnvironment, - localeBundles, - timeagoFormatter, - pym, - eventEmitter, - registerClickFarAway, - rest: new RestClient("/api", tokenGetter), - postMessage: new PostMessageService(), - localStorage: - (pym && inIframe && createPymStorage(pym, "localStorage")) || - createPromisifiedStorage(createLocalStorage()), - sessionStorage: - (pym && inIframe && createPymStorage(pym, "sessionStorage")) || - createPromisifiedStorage(createSessionStorage()), - browserInfo: getBrowserInfo(), - uuidGenerator: uuid, - }; - - // Run custom initializations. - await init(context); - - return context; -} diff --git a/src/core/client/framework/lib/bootstrap/createManaged.tsx b/src/core/client/framework/lib/bootstrap/createManaged.tsx new file mode 100644 index 000000000..f2d86e7c8 --- /dev/null +++ b/src/core/client/framework/lib/bootstrap/createManaged.tsx @@ -0,0 +1,252 @@ +import { EventEmitter2 } from "eventemitter2"; +import { Localized } from "fluent-react/compat"; +import { noop } from "lodash"; +import { Child as PymChild } from "pym.js"; +import React, { Component, ComponentType } from "react"; +import { Formatter } from "react-timeago"; +import { Environment, Network, RecordSource, Store } from "relay-runtime"; +import uuid from "uuid/v4"; + +import { getBrowserInfo } from "talk-framework/lib/browserInfo"; +import { LOCAL_ID } from "talk-framework/lib/relay"; +import { + createLocalStorage, + createPromisifiedStorage, + createPymStorage, + createSessionStorage, + PromisifiedStorage, +} from "talk-framework/lib/storage"; + +import { RestClient } from "talk-framework/lib/rest"; +import { ClickFarAwayRegister } from "talk-ui/components/ClickOutside"; + +import { generateBundles, LocalesData, negotiateLanguages } from "../i18n"; +import { createFetch, TokenGetter } from "../network"; +import { PostMessageService } from "../postMessage"; +import { TalkContext, TalkContextProvider } from "./TalkContext"; + +export type InitLocalState = (( + environment: Environment, + context: TalkContext +) => void | Promise); + +interface CreateContextArguments { + /** Locales that the user accepts, usually `navigator.languages`. */ + userLocales: ReadonlyArray; + + /** Locales data that is returned by our `locales-loader`. */ + localesData: LocalesData; + + /** Init will be called after the context has been created. */ + initLocalState?: InitLocalState; + + /** A pym child that interacts with the pym parent. */ + pym?: PymChild; + + /** Supports emitting and listening to events. */ + eventEmitter?: EventEmitter2; +} + +/** + * timeagoFormatter integrates timeago into our translation + * framework. It gets injected into the UIContext. + */ +export const timeagoFormatter: Formatter = (value, unit, suffix) => { + // We use 'in' instead of 'from now' for language consistency + const ourSuffix = suffix === "from now" ? "in" : suffix; + return ( + + now + + ); +}; + +/** + * Returns true if we are in an iframe. + */ +function areWeInIframe() { + try { + return window.self !== window.top; + } catch (e) { + return true; + } +} + +function createRelayEnvironment() { + // Initialize Relay. + const source = new RecordSource(); + const tokenGetter: TokenGetter = () => { + const localState = source.get(LOCAL_ID); + if (localState) { + return localState.authToken || ""; + } + return ""; + }; + const environment = new Environment({ + network: Network.create(createFetch(tokenGetter)), + store: new Store(source), + }); + return { environment, tokenGetter }; +} + +function createRestAPI(tokenGetter: (() => string)) { + return new RestClient("/api", tokenGetter); +} + +/** + * Returns a managed TalkContextProvider, that includes given context + * and handles context changes, e.g. when a user session changes. + */ +function createMangedTalkContextProvider( + context: TalkContext, + initLocalState: InitLocalState +) { + const ManagedTalkContextProvider = class extends Component< + {}, + { context: TalkContext } + > { + constructor(props: {}) { + super(props); + this.state = { + context: { + ...context, + clearSession: this.clearSession, + }, + }; + } + + // This is called every time a user session starts or ends. + private clearSession = async () => { + // Clear session storage. + this.state.context.sessionStorage.clear(); + + // Create a new context with a new Relay Environment. + const { + environment: newEnvironment, + tokenGetter: newTokenGetter, + } = createRelayEnvironment(); + + const newContext = { + ...this.state.context, + relayEnvironment: newEnvironment, + rest: createRestAPI(newTokenGetter), + }; + + // Initialize local state. + await initLocalState(newContext.relayEnvironment, newContext); + + // Propagate new context. + this.setState({ + context: newContext, + }); + }; + + public render() { + return ( + + {this.props.children} + + ); + } + }; + + return ManagedTalkContextProvider; +} + +/** + * resolveLocalStorage decides which local storage to use in the context + */ +function resolveLocalStorage(pym?: PymChild): PromisifiedStorage { + if (pym && areWeInIframe()) { + // Use local storage over pym when we have pym and are in an iframe. + return createPymStorage(pym, "localStorage"); + } + // Use promisified, prefixed local storage. + return createPromisifiedStorage(createLocalStorage()); +} + +/** + * resolveSessionStorage decides which session storage to use in the context + */ +function resolveSessionStorage(pym?: PymChild): PromisifiedStorage { + if (pym && areWeInIframe()) { + // Use session storage over pym when we have pym and are in an iframe. + return createPymStorage(pym, "sessionStorage"); + } + // Use promisified, prefixed session storage. + return createPromisifiedStorage(createSessionStorage()); +} + +/** + * `createManaged` establishes the dependencies of our framework + * and returns a `ManagedTalkContextProvider` that provides the context + * to the rest of the application. + */ +export default async function createManaged({ + initLocalState = noop, + userLocales, + localesData, + pym, + eventEmitter = new EventEmitter2({ wildcard: true, maxListeners: 20 }), +}: CreateContextArguments): Promise { + // Listen for outside clicks. + let registerClickFarAway: ClickFarAwayRegister | undefined; + if (pym) { + registerClickFarAway = cb => { + pym.onMessage("click", cb); + // Return unlisten callback. + return () => { + const index = pym.messageHandlers.click.indexOf(cb); + if (index > -1) { + pym.messageHandlers.click.splice(index, 1); + } + }; + }; + } + + // Initialize i18n. + const locales = negotiateLanguages(userLocales, localesData); + + if (process.env.NODE_ENV !== "production") { + // tslint:disable:next-line: no-console + console.log(`Negotiated locales ${JSON.stringify(locales)}`); + } + + const localeBundles = await generateBundles(locales, localesData); + + const localStorage = resolveLocalStorage(pym); + const sessionStorage = resolveSessionStorage(pym); + + const { environment, tokenGetter } = createRelayEnvironment(); + + // Assemble context. + const context: TalkContext = { + relayEnvironment: environment, + localeBundles, + timeagoFormatter, + pym, + eventEmitter, + registerClickFarAway, + rest: createRestAPI(tokenGetter), + postMessage: new PostMessageService(), + localStorage, + sessionStorage, + browserInfo: getBrowserInfo(), + uuidGenerator: uuid, + // Noop, this is later replaced by the + // managed TalkContextProvider. + clearSession: noop, + }; + + // Initialize local state. + await initLocalState(context.relayEnvironment, context); + + // Returns a managed TalkContextProvider, that includes the above + // context and handles context changes, e.g. when a user session changes. + return createMangedTalkContextProvider(context, initLocalState); +} diff --git a/src/core/client/framework/lib/bootstrap/index.ts b/src/core/client/framework/lib/bootstrap/index.ts index a6bd57eaa..0ef2ee79b 100644 --- a/src/core/client/framework/lib/bootstrap/index.ts +++ b/src/core/client/framework/lib/bootstrap/index.ts @@ -1,3 +1,7 @@ -export * from "./TalkContext"; -export { default as createContext } from "./createContext"; +export { + TalkContext, + TalkContextConsumer, + TalkContextProvider, +} from "./TalkContext"; +export { default as createManaged } from "./createManaged"; export { default as withContext } from "./withContext"; diff --git a/src/core/client/framework/lib/relay/commitLocalUpdatePromisified.ts b/src/core/client/framework/lib/relay/commitLocalUpdatePromisified.ts new file mode 100644 index 000000000..92d341cef --- /dev/null +++ b/src/core/client/framework/lib/relay/commitLocalUpdatePromisified.ts @@ -0,0 +1,18 @@ +import { + commitLocalUpdate, + Environment, + RecordSourceProxy, +} from "relay-runtime"; + +export default function commitLocalUpdatePromisified( + environment: Environment, + updater: (store: RecordSourceProxy) => Promise +) { + return new Promise((resolve, reject) => { + commitLocalUpdate(environment, store => { + updater(store) + .then(() => resolve()) + .catch(err => reject(err)); + }); + }); +} diff --git a/src/core/client/framework/lib/relay/index.ts b/src/core/client/framework/lib/relay/index.ts index 43dca5178..31bae3d63 100644 --- a/src/core/client/framework/lib/relay/index.ts +++ b/src/core/client/framework/lib/relay/index.ts @@ -13,3 +13,6 @@ export { commitMutationPromiseNormalized, } from "./commitMutationPromise"; export { graphql } from "react-relay"; +export { + default as commitLocalUpdatePromisified, +} from "./commitLocalUpdatePromisified"; diff --git a/src/core/client/framework/mutations/SetAuthTokenMutation.spec.ts b/src/core/client/framework/mutations/SetAuthTokenMutation.spec.ts index 887e6fd84..158dd0854 100644 --- a/src/core/client/framework/mutations/SetAuthTokenMutation.spec.ts +++ b/src/core/client/framework/mutations/SetAuthTokenMutation.spec.ts @@ -1,7 +1,9 @@ import { Environment, RecordSource } from "relay-runtime"; +import sinon from "sinon"; +import { TalkContext } from "talk-framework/lib/bootstrap"; import { LOCAL_ID } from "talk-framework/lib/relay"; -import { createInMemoryStorage } from "talk-framework/lib/storage"; +import { createPromisifiedStorage } from "talk-framework/lib/storage"; import { createRelayEnvironment } from "talk-framework/testHelpers"; import { commit } from "./SetAuthTokenMutation"; @@ -15,21 +17,29 @@ beforeAll(() => { }); }); -it("Sets auth token to localStorage", () => { - const context = { - localStorage: createInMemoryStorage(), +it("Sets auth token to localStorage", async () => { + const clearSessionStub = sinon.stub(); + const context: Partial = { + localStorage: createPromisifiedStorage(), + clearSession: clearSessionStub, }; const authToken = "auth token"; - commit(environment, { authToken }, context as any); + await commit(environment, { authToken }, context as any); expect(source.get(LOCAL_ID)!.authToken).toEqual(authToken); - expect(context.localStorage.getItem("authToken")).toEqual(authToken); + await expect(context.localStorage!.getItem("authToken")).resolves.toEqual( + authToken + ); + expect(clearSessionStub.calledOnce).toBe(true); }); -it("Removes auth token from localStorage", () => { - const context = { - localStorage: createInMemoryStorage(), +it("Removes auth token from localStorage", async () => { + const clearSessionStub = sinon.stub(); + const context: Partial = { + localStorage: createPromisifiedStorage(), + clearSession: clearSessionStub, }; localStorage.setItem("authToken", "tmp"); - commit(environment, { authToken: null }, context as any); - expect(context.localStorage.getItem("authToken")).toBeNull(); + await commit(environment, { authToken: null }, context as any); + await expect(context.localStorage!.getItem("authToken")).resolves.toBeNull(); + expect(clearSessionStub.calledOnce).toBe(true); }); diff --git a/src/core/client/framework/mutations/SetAuthTokenMutation.ts b/src/core/client/framework/mutations/SetAuthTokenMutation.ts index 90e520155..f0da6df92 100644 --- a/src/core/client/framework/mutations/SetAuthTokenMutation.ts +++ b/src/core/client/framework/mutations/SetAuthTokenMutation.ts @@ -1,7 +1,10 @@ -import { commitLocalUpdate, Environment } from "relay-runtime"; +import { Environment } from "relay-runtime"; import { TalkContext } from "talk-framework/lib/bootstrap"; -import { createMutationContainer } from "talk-framework/lib/relay"; +import { + commitLocalUpdatePromisified, + createMutationContainer, +} from "talk-framework/lib/relay"; import { LOCAL_ID } from "talk-framework/lib/relay/withLocalStateContainer"; export interface SetAuthTokenInput { @@ -13,27 +16,18 @@ export type SetAuthTokenMutation = (input: SetAuthTokenInput) => Promise; export async function commit( environment: Environment, input: SetAuthTokenInput, - { localStorage }: TalkContext + { localStorage, clearSession }: TalkContext ) { - return commitLocalUpdate(environment, store => { + return await commitLocalUpdatePromisified(environment, async store => { const record = store.get(LOCAL_ID)!; record.setValue(input.authToken, "authToken"); if (input.authToken) { - localStorage.setItem("authToken", input.authToken); + await localStorage.setItem("authToken", input.authToken); } else { - localStorage.removeItem("authToken"); + await localStorage.removeItem("authToken"); } - // Increment auth revision to indicate a change in auth state. - record.setValue(record.getValue("authRevision") + 1, "authRevision"); - - // Force gc to trigger. - environment - .retain({ - dataID: "tmp", - node: { selections: [] }, - variables: {}, - }) - .dispose(); + // Clear current session, as we are starting a new one. + clearSession(); }); } diff --git a/src/core/client/stream/index.tsx b/src/core/client/stream/index.tsx index 31fade961..7dde43c00 100644 --- a/src/core/client/stream/index.tsx +++ b/src/core/client/stream/index.tsx @@ -3,46 +3,40 @@ import React from "react"; import { StatelessComponent } from "react"; import ReactDOM from "react-dom"; -import { - createContext, - TalkContext, - TalkContextProvider, -} from "talk-framework/lib/bootstrap"; +import { createManaged } from "talk-framework/lib/bootstrap"; import AppContainer from "./containers/AppContainer"; import { - onPostMessageAuthError, - onPostMessageSetAuthToken, - onPymSetCommentID, + OnPostMessageAuthError, + OnPostMessageSetAuthToken, + OnPymSetCommentID, } from "./listeners"; import { initLocalState } from "./local"; import localesData from "./locales"; -const listeners = [ - onPymSetCommentID, - onPostMessageSetAuthToken, - onPostMessageAuthError, -]; - -// This is called when the context is first initialized. -async function init(context: TalkContext) { - await initLocalState(context.relayEnvironment, context); - listeners.forEach(f => f(context)); -} +const listeners = ( + <> + + + + +); async function main() { - // Bootstrap our context. - const context = await createContext({ - init, + const ManagedTalkContextProvider = await createManaged({ + initLocalState, localesData, userLocales: navigator.languages, pym: new PymChild({ polling: 100 }), }); const Index: StatelessComponent = () => ( - - - + + <> + {listeners} + + + ); ReactDOM.render(, document.getElementById("app")); diff --git a/src/core/client/stream/listeners/OnPostMessageAuthError.tsx b/src/core/client/stream/listeners/OnPostMessageAuthError.tsx new file mode 100644 index 000000000..3bb851965 --- /dev/null +++ b/src/core/client/stream/listeners/OnPostMessageAuthError.tsx @@ -0,0 +1,28 @@ +import { Component } from "react"; + +import { withContext } from "talk-framework/lib/bootstrap"; +import { PostMessageService } from "talk-framework/lib/postMessage"; + +interface Props { + postMessage: PostMessageService; +} + +class OnPostMessageAuthError extends Component { + constructor(props: Props) { + super(props); + // Auth popup will use this to send back errors during login. + props.postMessage!.on("authError", error => { + // tslint:disable-next-line:no-console + console.error(error); + }); + } + + public render() { + return null; + } +} + +const enhanced = withContext(({ postMessage }) => ({ postMessage }))( + OnPostMessageAuthError +); +export default enhanced; diff --git a/src/core/client/stream/listeners/onPostMessageSetAuthToken.spec.ts b/src/core/client/stream/listeners/OnPostMessageSetAuthToken.spec.tsx similarity index 57% rename from src/core/client/stream/listeners/onPostMessageSetAuthToken.spec.ts rename to src/core/client/stream/listeners/OnPostMessageSetAuthToken.spec.tsx index c35823c8a..da2eb5816 100644 --- a/src/core/client/stream/listeners/onPostMessageSetAuthToken.spec.ts +++ b/src/core/client/stream/listeners/OnPostMessageSetAuthToken.spec.tsx @@ -1,10 +1,14 @@ +import { shallow } from "enzyme"; +import { noop } from "lodash"; +import React from "react"; import { Environment, RecordSource } from "relay-runtime"; +import { TalkContext } from "talk-framework/lib/bootstrap"; import { LOCAL_ID } from "talk-framework/lib/relay"; -import { createInMemoryStorage } from "talk-framework/lib/storage"; +import { createPromisifiedStorage } from "talk-framework/lib/storage"; import { createRelayEnvironment } from "talk-framework/testHelpers"; -import onPostMessageSetAuthToken from "./onPostMessageSetAuthToken"; +import { OnPostMessageSetAuthToken } from "./OnPostMessageSetAuthToken"; let relayEnvironment: Environment; const source: RecordSource = new RecordSource(); @@ -17,16 +21,17 @@ beforeAll(() => { it("Sets auth token", () => { const token = "auth-token"; - const context = { + const context: Partial = { postMessage: { on: (name: string, cb: (token: string) => void) => { expect(name).toBe("setAuthToken"); cb(token); }, - }, + } as any, relayEnvironment, - localStorage: createInMemoryStorage(), + localStorage: createPromisifiedStorage(), + clearSession: noop, }; - onPostMessageSetAuthToken(context as any); + shallow(); expect(source.get(LOCAL_ID)!.authToken).toEqual(token); }); diff --git a/src/core/client/stream/listeners/OnPostMessageSetAuthToken.ts b/src/core/client/stream/listeners/OnPostMessageSetAuthToken.ts new file mode 100644 index 000000000..553e4fa5b --- /dev/null +++ b/src/core/client/stream/listeners/OnPostMessageSetAuthToken.ts @@ -0,0 +1,31 @@ +import { Component } from "react"; + +import { TalkContext, withContext } from "talk-framework/lib/bootstrap"; +import { commit as setAuthToken } from "talk-framework/mutations/SetAuthTokenMutation"; + +interface Props { + context: TalkContext; +} + +export class OnPostMessageSetAuthToken extends Component { + constructor(props: Props) { + super(props); + // Auth popup will use this to handle a successful login. + props.context.postMessage!.on("setAuthToken", (authToken: string) => { + setAuthToken( + this.props.context.relayEnvironment, + { authToken }, + this.props.context + ); + }); + } + + public render() { + return null; + } +} + +const enhanced = withContext(context => ({ context }))( + OnPostMessageSetAuthToken +); +export default enhanced; diff --git a/src/core/client/stream/listeners/onPymSetCommentID.spec.ts b/src/core/client/stream/listeners/OnPymSetCommentID.spec.tsx similarity index 77% rename from src/core/client/stream/listeners/onPymSetCommentID.spec.ts rename to src/core/client/stream/listeners/OnPymSetCommentID.spec.tsx index 6b5e67ea2..20132c1f7 100644 --- a/src/core/client/stream/listeners/onPymSetCommentID.spec.ts +++ b/src/core/client/stream/listeners/OnPymSetCommentID.spec.tsx @@ -1,9 +1,11 @@ +import { shallow } from "enzyme"; +import React from "react"; import { Environment, RecordSource } from "relay-runtime"; import { LOCAL_ID } from "talk-framework/lib/relay"; import { createRelayEnvironment } from "talk-framework/testHelpers"; -import onPymSetCommentID from "./onPymSetCommentID"; +import { OnPymSetCommentID } from "./OnPymSetCommentID"; let relayEnvironment: Environment; const source: RecordSource = new RecordSource(); @@ -16,30 +18,30 @@ beforeAll(() => { it("Sets comment id", () => { const id = "comment1-id"; - const context = { + const props = { pym: { onMessage: (eventName: string, cb: (id: string) => void) => { expect(eventName).toBe("setCommentID"); cb(id); }, - }, + } as any, relayEnvironment, }; - onPymSetCommentID(context as any); + shallow(); expect(source.get(LOCAL_ID)!.commentID).toEqual(id); }); it("Sets comment id to null when empty", () => { const id = ""; - const context = { + const props = { pym: { onMessage: (eventName: string, cb: (data: string) => void) => { expect(eventName).toBe("setCommentID"); cb(id); }, - }, + } as any, relayEnvironment, }; - onPymSetCommentID(context as any); + shallow(); expect(source.get(LOCAL_ID)!.commentID).toEqual(null); }); diff --git a/src/core/client/stream/listeners/OnPymSetCommentID.ts b/src/core/client/stream/listeners/OnPymSetCommentID.ts new file mode 100644 index 000000000..f9ca0c6f5 --- /dev/null +++ b/src/core/client/stream/listeners/OnPymSetCommentID.ts @@ -0,0 +1,39 @@ +import { Child } from "pym.js"; +import { Component } from "react"; +import { commitLocalUpdate } from "react-relay"; +import { Environment } from "relay-runtime"; + +import { withContext } from "talk-framework/lib/bootstrap"; +import { LOCAL_ID } from "talk-framework/lib/relay"; + +interface Props { + relayEnvironment: Environment; + pym: Child; +} + +export class OnPymSetCommentID extends Component { + constructor(props: Props) { + super(props); + + // Sets comment id through pym. + props.pym!.onMessage("setCommentID", raw => { + commitLocalUpdate(this.props.relayEnvironment, s => { + const id = raw || null; + if (s.get(LOCAL_ID)!.getValue("commentID") !== id) { + s.get(LOCAL_ID)!.setValue(id, "commentID"); + } + }); + }); + } + + public render() { + return null; + } +} + +const enhanced = withContext(({ relayEnvironment, pym }) => ({ + relayEnvironment, + pym, +}))(OnPymSetCommentID); + +export default enhanced; diff --git a/src/core/client/stream/listeners/index.ts b/src/core/client/stream/listeners/index.ts index 7901544e0..1c315d81f 100644 --- a/src/core/client/stream/listeners/index.ts +++ b/src/core/client/stream/listeners/index.ts @@ -1,5 +1,5 @@ -export { default as onPymSetCommentID } from "./onPymSetCommentID"; +export { default as OnPymSetCommentID } from "./OnPymSetCommentID"; export { - default as onPostMessageSetAuthToken, -} from "./onPostMessageSetAuthToken"; -export { default as onPostMessageAuthError } from "./onPostMessageAuthError"; + default as OnPostMessageSetAuthToken, +} from "./OnPostMessageSetAuthToken"; +export { default as OnPostMessageAuthError } from "./OnPostMessageAuthError"; diff --git a/src/core/client/stream/listeners/onPostMessageAuthError.ts b/src/core/client/stream/listeners/onPostMessageAuthError.ts deleted file mode 100644 index 668a4ba1f..000000000 --- a/src/core/client/stream/listeners/onPostMessageAuthError.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { TalkContext } from "talk-framework/lib/bootstrap"; - -export default function onPostMessageSetAuthToken({ - postMessage, -}: TalkContext) { - // Auth popup will use this to send back errors during login. - postMessage!.on("authError", error => { - // tslint:disable-next-line:no-console - console.error(error); - }); -} diff --git a/src/core/client/stream/listeners/onPostMessageSetAuthToken.ts b/src/core/client/stream/listeners/onPostMessageSetAuthToken.ts deleted file mode 100644 index 767af51f7..000000000 --- a/src/core/client/stream/listeners/onPostMessageSetAuthToken.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { TalkContext } from "talk-framework/lib/bootstrap"; - -import { commit as setAuthToken } from "talk-framework/mutations/SetAuthTokenMutation"; - -export default function onPostMessageSetAuthToken(ctx: TalkContext) { - const { relayEnvironment, postMessage } = ctx; - // Auth popup will use this to handle a successful login. - postMessage!.on("setAuthToken", (authToken: string) => { - setAuthToken(relayEnvironment, { authToken }, ctx); - }); -} diff --git a/src/core/client/stream/listeners/onPymSetCommentID.ts b/src/core/client/stream/listeners/onPymSetCommentID.ts deleted file mode 100644 index 4b5547173..000000000 --- a/src/core/client/stream/listeners/onPymSetCommentID.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { commitLocalUpdate } from "react-relay"; - -import { TalkContext } from "talk-framework/lib/bootstrap"; -import { LOCAL_ID } from "talk-framework/lib/relay"; - -export default function onPymSetCommentID({ - relayEnvironment, - pym, -}: TalkContext) { - // Sets comment id through pym. - pym!.onMessage("setCommentID", raw => { - commitLocalUpdate(relayEnvironment, s => { - const id = raw || null; - if (s.get(LOCAL_ID)!.getValue("commentID") !== id) { - s.get(LOCAL_ID)!.setValue(id, "commentID"); - } - }); - }); -} diff --git a/src/core/client/stream/local/__snapshots__/initLocalState.spec.ts.snap b/src/core/client/stream/local/__snapshots__/initLocalState.spec.ts.snap index b95cc19ce..c8dda11c7 100644 --- a/src/core/client/stream/local/__snapshots__/initLocalState.spec.ts.snap +++ b/src/core/client/stream/local/__snapshots__/initLocalState.spec.ts.snap @@ -13,7 +13,6 @@ exports[`init local state 1`] = ` \\"__id\\": \\"client:root.local\\", \\"__typename\\": \\"Local\\", \\"authToken\\": \\"\\", - \\"authRevision\\": 0, \\"network\\": { \\"__ref\\": \\"client:root.local.network\\" }, diff --git a/src/core/client/stream/local/initLocalState.ts b/src/core/client/stream/local/initLocalState.ts index 2d953e6ae..5b127755d 100644 --- a/src/core/client/stream/local/initLocalState.ts +++ b/src/core/client/stream/local/initLocalState.ts @@ -35,9 +35,6 @@ export default async function initLocalState( // Set auth token localRecord.setValue(authToken || "", "authToken"); - // Set initial auth revision, this is increment whenenver auth state might have changed. - localRecord.setValue(0, "authRevision"); - // Parse query params const query = qs.parse(location.search); diff --git a/src/core/client/stream/local/local.graphql b/src/core/client/stream/local/local.graphql index 9e04c476a..37615ec9d 100644 --- a/src/core/client/stream/local/local.graphql +++ b/src/core/client/stream/local/local.graphql @@ -26,10 +26,6 @@ type Local { commentID: String authPopup: AuthPopup! authToken: String - # Used to invalidate the `me` endpoint. - # This is incremented whenever the auth status - # might have changed. - authRevision: Int! } extend type Query { diff --git a/src/core/client/stream/queries/PermalinkViewQuery.tsx b/src/core/client/stream/queries/PermalinkViewQuery.tsx index fc098dafe..4d1ac4611 100644 --- a/src/core/client/stream/queries/PermalinkViewQuery.tsx +++ b/src/core/client/stream/queries/PermalinkViewQuery.tsx @@ -45,19 +45,12 @@ export const render = ({ }; const PermalinkViewQuery: StatelessComponent = ({ - local: { commentID, assetID, authRevision }, + local: { commentID, assetID }, }) => ( query={graphql` - query PermalinkViewQuery( - $commentID: ID! - $assetID: ID! - $authRevision: Int! - ) { - # authRevision is increment every time auth state has changed. - # This is basically a cache invalidation and causes relay - # to automatically update this query. - me(clientAuthRevision: $authRevision) { + query PermalinkViewQuery($commentID: ID!, $assetID: ID!) { + me { ...PermalinkViewContainer_me } asset(id: $assetID) { @@ -71,7 +64,6 @@ const PermalinkViewQuery: StatelessComponent = ({ variables={{ assetID: assetID!, commentID: commentID!, - authRevision, }} render={render} /> @@ -81,7 +73,6 @@ const enhanced = withLocalStateContainer( graphql` fragment PermalinkViewQueryLocal on Local { assetID - authRevision commentID } ` diff --git a/src/core/client/stream/queries/StreamQuery.tsx b/src/core/client/stream/queries/StreamQuery.tsx index b0f1f0c3f..ec61ec723 100644 --- a/src/core/client/stream/queries/StreamQuery.tsx +++ b/src/core/client/stream/queries/StreamQuery.tsx @@ -38,15 +38,12 @@ export const render = ({ }; const StreamQuery: StatelessComponent = ({ - local: { assetID, authRevision }, + local: { assetID }, }) => ( query={graphql` - query StreamQuery($assetID: ID!, $authRevision: Int!) { - # authRevision is increment every time auth state has changed. - # This is basically a cache invalidation and causes relay - # to automatically update this query. - me(clientAuthRevision: $authRevision) { + query StreamQuery($assetID: ID!) { + me { ...StreamContainer_me } asset(id: $assetID) { @@ -56,7 +53,6 @@ const StreamQuery: StatelessComponent = ({ `} variables={{ assetID: assetID!, - authRevision, }} render={render} /> @@ -66,7 +62,6 @@ const enhanced = withLocalStateContainer( graphql` fragment StreamQueryLocal on Local { assetID - authRevision } ` )(StreamQuery); diff --git a/src/core/client/stream/test/create.tsx b/src/core/client/stream/test/create.tsx index 49d885c66..2eecbc5a1 100644 --- a/src/core/client/stream/test/create.tsx +++ b/src/core/client/stream/test/create.tsx @@ -1,4 +1,6 @@ +import { EventEmitter2 } from "eventemitter2"; import { IResolvers } from "graphql-tools"; +import { noop } from "lodash"; import React from "react"; import TestRenderer from "react-test-renderer"; import { Environment, RecordProxy, RecordSourceProxy } from "relay-runtime"; @@ -30,7 +32,6 @@ export default function create(params: CreateParams) { logNetwork: params.logNetwork, resolvers: params.resolvers, initLocalState: (localRecord, source, env) => { - localRecord.setValue(0, "authRevision"); if (params.initLocalState) { params.initLocalState(localRecord, source, env); } @@ -46,6 +47,8 @@ export default function create(params: CreateParams) { postMessage: new PostMessageService(), browserInfo: { ios: false }, uuidGenerator: createUUIDGenerator(), + eventEmitter: new EventEmitter2({ wildcard: true, maxListeners: 20 }), + clearSession: noop, }; const testRenderer = TestRenderer.create( diff --git a/src/core/client/stream/test/postComment.spec.tsx b/src/core/client/stream/test/postComment.spec.tsx index 658a9d036..78120589d 100644 --- a/src/core/client/stream/test/postComment.spec.tsx +++ b/src/core/client/stream/test/postComment.spec.tsx @@ -11,14 +11,8 @@ let testRenderer: ReactTestRenderer; beforeEach(() => { const resolvers = { Query: { - asset: createSinonStub( - s => s.throws(), - s => s.withArgs(undefined, { id: assets[0].id }).returns(assets[0]) - ), - me: createSinonStub( - s => s.throws(), - s => s.withArgs(undefined, { clientAuthRevision: 0 }).returns(users[0]) - ), + asset: createSinonStub(s => s.throws(), s => s.returns(assets[0])), + me: createSinonStub(s => s.throws(), s => s.returns(users[0])), }, Mutation: { createComment: createSinonStub( diff --git a/src/core/client/stream/test/postReply.spec.tsx b/src/core/client/stream/test/postReply.spec.tsx index 3c64bc098..dd7f0eb6e 100644 --- a/src/core/client/stream/test/postReply.spec.tsx +++ b/src/core/client/stream/test/postReply.spec.tsx @@ -11,14 +11,8 @@ let testRenderer: ReactTestRenderer; beforeEach(() => { const resolvers = { Query: { - asset: createSinonStub( - s => s.throws(), - s => s.withArgs(undefined, { id: assets[0].id }).returns(assets[0]) - ), - me: createSinonStub( - s => s.throws(), - s => s.withArgs(undefined, { clientAuthRevision: 0 }).returns(users[0]) - ), + asset: createSinonStub(s => s.throws(), s => s.returns(assets[0])), + me: createSinonStub(s => s.throws(), s => s.returns(users[0])), }, Mutation: { createComment: createSinonStub( diff --git a/src/core/server/graph/tenant/schema/schema.graphql b/src/core/server/graph/tenant/schema/schema.graphql index dc689f403..3dcdb0894 100644 --- a/src/core/server/graph/tenant/schema/schema.graphql +++ b/src/core/server/graph/tenant/schema/schema.graphql @@ -809,12 +809,8 @@ type Query { """ me is the current logged in User. - - clientAuthRevision is an implementation detail that is only - used on the client to invalidate the cache. - TODO: This should move to a client side directive if this becomes possible. """ - me(clientAuthRevision: Int): User + me: User """ settings is the Settings for a given Tenant.