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/__snapshots__/navigation.spec.tsx.snap b/src/core/client/auth/test/__snapshots__/navigation.spec.tsx.snap index c3c5fe4ed..47693769e 100644 --- a/src/core/client/auth/test/__snapshots__/navigation.spec.tsx.snap +++ b/src/core/client/auth/test/__snapshots__/navigation.spec.tsx.snap @@ -216,7 +216,7 @@ exports[`navigates to sign up form 1`] = ` Username

A unique identifier displayed on your comments. You may use “_” and “.”

@@ -239,7 +239,7 @@ exports[`navigates to sign up form 1`] = ` Password

Must be at least ⁨8⁩ characters

diff --git a/src/core/client/auth/test/__snapshots__/signIn.spec.tsx.snap b/src/core/client/auth/test/__snapshots__/signIn.spec.tsx.snap index cb01d2c1b..898850de7 100644 --- a/src/core/client/auth/test/__snapshots__/signIn.spec.tsx.snap +++ b/src/core/client/auth/test/__snapshots__/signIn.spec.tsx.snap @@ -34,11 +34,11 @@ exports[`accepts correct password 1`] = ` value="" />
@@ -180,11 +180,11 @@ exports[`accepts valid email 1`] = ` value="" />
@@ -290,11 +290,11 @@ exports[`checks for invalid email 1`] = ` value="invalid-email" />
@@ -321,11 +321,11 @@ exports[`checks for invalid email 1`] = ` value="" />
@@ -546,11 +546,11 @@ exports[`shows error when submitting empty form 1`] = ` value="" />
@@ -577,11 +577,11 @@ exports[`shows error when submitting empty form 1`] = ` value="" />
diff --git a/src/core/client/auth/test/__snapshots__/signUp.spec.tsx.snap b/src/core/client/auth/test/__snapshots__/signUp.spec.tsx.snap index 6293da7d8..5491404be 100644 --- a/src/core/client/auth/test/__snapshots__/signUp.spec.tsx.snap +++ b/src/core/client/auth/test/__snapshots__/signUp.spec.tsx.snap @@ -34,11 +34,11 @@ exports[`accepts correct password 1`] = ` value="" />
@@ -56,7 +56,7 @@ exports[`accepts correct password 1`] = ` Username

A unique identifier displayed on your comments. You may use “_” and “.”

@@ -70,11 +70,11 @@ exports[`accepts correct password 1`] = ` value="" />
@@ -92,7 +92,7 @@ exports[`accepts correct password 1`] = ` Password

Must be at least ⁨8⁩ characters

@@ -124,11 +124,11 @@ exports[`accepts correct password 1`] = ` value="" />
@@ -215,11 +215,11 @@ exports[`accepts correct password confirmation 1`] = ` value="" />
@@ -237,7 +237,7 @@ exports[`accepts correct password confirmation 1`] = ` Username

A unique identifier displayed on your comments. You may use “_” and “.”

@@ -251,11 +251,11 @@ exports[`accepts correct password confirmation 1`] = ` value="" />
@@ -273,7 +273,7 @@ exports[`accepts correct password confirmation 1`] = ` Password

Must be at least ⁨8⁩ characters

@@ -287,11 +287,11 @@ exports[`accepts correct password confirmation 1`] = ` value="" />
@@ -318,11 +318,11 @@ exports[`accepts correct password confirmation 1`] = ` value="testtest" />
@@ -418,7 +418,7 @@ exports[`accepts valid email 1`] = ` Username

A unique identifier displayed on your comments. You may use “_” and “.”

@@ -432,11 +432,11 @@ exports[`accepts valid email 1`] = ` value="" />
@@ -454,7 +454,7 @@ exports[`accepts valid email 1`] = ` Password

Must be at least ⁨8⁩ characters

@@ -468,11 +468,11 @@ exports[`accepts valid email 1`] = ` value="" />
@@ -499,11 +499,11 @@ exports[`accepts valid email 1`] = ` value="" />
@@ -590,11 +590,11 @@ exports[`accepts valid username 1`] = ` value="" />
@@ -612,7 +612,7 @@ exports[`accepts valid username 1`] = ` Username

A unique identifier displayed on your comments. You may use “_” and “.”

@@ -635,7 +635,7 @@ exports[`accepts valid username 1`] = ` Password

Must be at least ⁨8⁩ characters

@@ -649,11 +649,11 @@ exports[`accepts valid username 1`] = ` value="" />
@@ -680,11 +680,11 @@ exports[`accepts valid username 1`] = ` value="" />
@@ -771,11 +771,11 @@ exports[`checks for invalid characters in username 1`] = ` value="" />
@@ -793,7 +793,7 @@ exports[`checks for invalid characters in username 1`] = ` Username

A unique identifier displayed on your comments. You may use “_” and “.”

@@ -807,11 +807,11 @@ exports[`checks for invalid characters in username 1`] = ` value="$%$§$%$§%" />
@@ -829,7 +829,7 @@ exports[`checks for invalid characters in username 1`] = ` Password

Must be at least ⁨8⁩ characters

@@ -843,11 +843,11 @@ exports[`checks for invalid characters in username 1`] = ` value="" />
@@ -874,11 +874,11 @@ exports[`checks for invalid characters in username 1`] = ` value="" />
@@ -965,11 +965,11 @@ exports[`checks for invalid email 1`] = ` value="invalid-email" />
@@ -987,7 +987,7 @@ exports[`checks for invalid email 1`] = ` Username

A unique identifier displayed on your comments. You may use “_” and “.”

@@ -1001,11 +1001,11 @@ exports[`checks for invalid email 1`] = ` value="" />
@@ -1023,7 +1023,7 @@ exports[`checks for invalid email 1`] = ` Password

Must be at least ⁨8⁩ characters

@@ -1037,11 +1037,11 @@ exports[`checks for invalid email 1`] = ` value="" />
@@ -1068,11 +1068,11 @@ exports[`checks for invalid email 1`] = ` value="" />
@@ -1159,11 +1159,11 @@ exports[`checks for too long username 1`] = ` value="" />
@@ -1181,7 +1181,7 @@ exports[`checks for too long username 1`] = ` Username

A unique identifier displayed on your comments. You may use “_” and “.”

@@ -1195,11 +1195,11 @@ exports[`checks for too long username 1`] = ` value="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" />
@@ -1217,7 +1217,7 @@ exports[`checks for too long username 1`] = ` Password

Must be at least ⁨8⁩ characters

@@ -1231,11 +1231,11 @@ exports[`checks for too long username 1`] = ` value="" />
@@ -1262,11 +1262,11 @@ exports[`checks for too long username 1`] = ` value="" />
@@ -1353,11 +1353,11 @@ exports[`checks for too short password 1`] = ` value="" />
@@ -1375,7 +1375,7 @@ exports[`checks for too short password 1`] = ` Username

A unique identifier displayed on your comments. You may use “_” and “.”

@@ -1389,11 +1389,11 @@ exports[`checks for too short password 1`] = ` value="" />
@@ -1411,7 +1411,7 @@ exports[`checks for too short password 1`] = ` Password

Must be at least ⁨8⁩ characters

@@ -1425,11 +1425,11 @@ exports[`checks for too short password 1`] = ` value="pass" />
@@ -1456,11 +1456,11 @@ exports[`checks for too short password 1`] = ` value="" />
@@ -1547,11 +1547,11 @@ exports[`checks for too short username 1`] = ` value="" />
@@ -1569,7 +1569,7 @@ exports[`checks for too short username 1`] = ` Username

A unique identifier displayed on your comments. You may use “_” and “.”

@@ -1583,11 +1583,11 @@ exports[`checks for too short username 1`] = ` value="u" />
@@ -1605,7 +1605,7 @@ exports[`checks for too short username 1`] = ` Password

Must be at least ⁨8⁩ characters

@@ -1619,11 +1619,11 @@ exports[`checks for too short username 1`] = ` value="" />
@@ -1650,11 +1650,11 @@ exports[`checks for too short username 1`] = ` value="" />
@@ -1741,11 +1741,11 @@ exports[`checks for wrong password confirmation 1`] = ` value="" />
@@ -1763,7 +1763,7 @@ exports[`checks for wrong password confirmation 1`] = ` Username

A unique identifier displayed on your comments. You may use “_” and “.”

@@ -1777,11 +1777,11 @@ exports[`checks for wrong password confirmation 1`] = ` value="" />
@@ -1799,7 +1799,7 @@ exports[`checks for wrong password confirmation 1`] = ` Password

Must be at least ⁨8⁩ characters

@@ -1813,11 +1813,11 @@ exports[`checks for wrong password confirmation 1`] = ` value="" />
@@ -1844,11 +1844,11 @@ exports[`checks for wrong password confirmation 1`] = ` value="not-matching" />
@@ -1944,7 +1944,7 @@ exports[`renders sign up form 1`] = ` Username

A unique identifier displayed on your comments. You may use “_” and “.”

@@ -1967,7 +1967,7 @@ exports[`renders sign up form 1`] = ` Password

Must be at least ⁨8⁩ characters

@@ -2077,11 +2077,11 @@ exports[`shows error when submitting empty form 1`] = ` value="" />
@@ -2099,7 +2099,7 @@ exports[`shows error when submitting empty form 1`] = ` Username

A unique identifier displayed on your comments. You may use “_” and “.”

@@ -2113,11 +2113,11 @@ exports[`shows error when submitting empty form 1`] = ` value="" />
@@ -2135,7 +2135,7 @@ exports[`shows error when submitting empty form 1`] = ` Password

Must be at least ⁨8⁩ characters

@@ -2149,11 +2149,11 @@ exports[`shows error when submitting empty form 1`] = ` value="" />
@@ -2180,11 +2180,11 @@ exports[`shows error when submitting empty form 1`] = ` value="" />
@@ -2280,7 +2280,7 @@ exports[`shows server error 1`] = ` Username

A unique identifier displayed on your comments. You may use “_” and “.”

@@ -2303,7 +2303,7 @@ exports[`shows server error 1`] = ` Password

Must be at least ⁨8⁩ characters

@@ -2427,7 +2427,7 @@ exports[`shows server error 2`] = ` Username

A unique identifier displayed on your comments. You may use “_” and “.”

@@ -2450,7 +2450,7 @@ exports[`shows server error 2`] = ` Password

Must be at least ⁨8⁩ characters

@@ -2569,7 +2569,7 @@ exports[`submits form successfully 1`] = ` Username

A unique identifier displayed on your comments. You may use “_” and “.”

@@ -2592,7 +2592,7 @@ exports[`submits form successfully 1`] = ` Password

Must be at least ⁨8⁩ characters

@@ -2711,7 +2711,7 @@ exports[`submits form successfully 2`] = ` Username

A unique identifier displayed on your comments. You may use “_” and “.”

@@ -2734,7 +2734,7 @@ exports[`submits form successfully 2`] = ` Password

Must be at least ⁨8⁩ characters

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..bc1219427 --- /dev/null +++ b/src/core/client/framework/lib/bootstrap/createManaged.tsx @@ -0,0 +1,261 @@ +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" ? "noSuffix" : suffix; + + if (unit === "second" && suffix === "ago") { + return ( + + Just now + + ); + } + + 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/createMutationContainer.tsx b/src/core/client/framework/lib/relay/createMutationContainer.tsx index b8c74be18..3084635c9 100644 --- a/src/core/client/framework/lib/relay/createMutationContainer.tsx +++ b/src/core/client/framework/lib/relay/createMutationContainer.tsx @@ -42,7 +42,7 @@ function createMutationContainer( }; public render() { - const { relayEnvironment: _, ...rest } = this.props; + const { context: _, ...rest } = this.props; const inject = { [propName]: this.commit, }; 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/lib/relay/withLocalStateContainer.tsx b/src/core/client/framework/lib/relay/withLocalStateContainer.tsx index 444b67fa2..484928fc7 100644 --- a/src/core/client/framework/lib/relay/withLocalStateContainer.tsx +++ b/src/core/client/framework/lib/relay/withLocalStateContainer.tsx @@ -39,6 +39,7 @@ export const LOCAL_ID = "client:root.local"; function withLocalStateContainer( fragmentSpec: GraphQLTaggedNode ): InferableComponentEnhancer<{ local: _RefType }> { + const fragment = (fragmentSpec as any).data().default; return compose( withContext(({ relayEnvironment }) => ({ relayEnvironment })), hoistStatics((BaseComponent: React.ComponentType) => { @@ -49,9 +50,7 @@ function withLocalStateContainer( ); private subscription: Disposable; - constructor(props: Props) { - super(props); - const fragment = (fragmentSpec as any).data().default; + private subscribe(environment: Environment) { if (fragment.kind !== "Fragment") { throw new Error("Expected fragment"); } @@ -65,24 +64,44 @@ function withLocalStateContainer( node: { selections: fragment.selections }, variables: {}, }; - const snapshot = props.relayEnvironment.lookup(selector); - this.subscription = props.relayEnvironment.subscribe( + const snapshot = environment.lookup(selector); + this.subscription = environment.subscribe( snapshot, this.updateSnapshot ); - this.state = { - data: snapshot.data, - }; + this.updateSnapshot(snapshot); + } + + constructor(props: Props) { + super(props); + this.subscribe(props.relayEnvironment); } private updateSnapshot = (snapshot: CSnapshot) => { - this.setState({ data: snapshot.data }); + const nextState = { data: snapshot.data }; + // State has not been initialized yet. + if (!this.state) { + this.state = nextState; + return; + } + this.setState(nextState); }; - public componentWillUnmount() { + private unsubscribe() { this.subscription.dispose(); } + public componentWillReceiveProps(next: Props) { + if (this.props.relayEnvironment !== next.relayEnvironment) { + this.unsubscribe(); + this.subscribe(next.relayEnvironment); + } + } + + public componentWillUnmount() { + this.unsubscribe(); + } + public render() { const { relayEnvironment: _, ...rest } = this.props; return ; 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/components/App.spec.tsx b/src/core/client/stream/components/App.spec.tsx index d7dfdc3a7..dcd979099 100644 --- a/src/core/client/stream/components/App.spec.tsx +++ b/src/core/client/stream/components/App.spec.tsx @@ -5,17 +5,9 @@ import { PropTypesOf } from "talk-framework/types"; import App from "./App"; -it("renders stream", () => { +it("renders comments", () => { const props: PropTypesOf = { - showPermalinkView: false, - }; - const wrapper = shallow(); - expect(wrapper).toMatchSnapshot(); -}); - -it("renders permalink view", () => { - const props: PropTypesOf = { - showPermalinkView: true, + activeTab: "COMMENTS", }; const wrapper = shallow(); expect(wrapper).toMatchSnapshot(); diff --git a/src/core/client/stream/components/App.tsx b/src/core/client/stream/components/App.tsx index b7d2d09d9..421e6af9c 100644 --- a/src/core/client/stream/components/App.tsx +++ b/src/core/client/stream/components/App.tsx @@ -3,20 +3,22 @@ import { StatelessComponent } from "react"; import { Flex } from "talk-ui/components"; -import PermalinkViewQuery from "../queries/PermalinkViewQuery"; -import StreamQuery from "../queries/StreamQuery"; +import CommentsPaneContainer from "../tabs/comments/containers/CommentsPaneContainer"; import * as styles from "./App.css"; export interface AppProps { - showPermalinkView: boolean; + activeTab: "COMMENTS" | "%future added value"; } const App: StatelessComponent = props => { - const view = props.showPermalinkView ? ( - - ) : ( - - ); + let view: React.ReactElement; + switch (props.activeTab) { + case "COMMENTS": + view = ; + break; + default: + throw new Error(`Unknown tab ${props.activeTab}`); + } return ( {view} diff --git a/src/core/client/stream/components/Comment/Comment.tsx b/src/core/client/stream/components/Comment/Comment.tsx deleted file mode 100644 index bd4fc17f7..000000000 --- a/src/core/client/stream/components/Comment/Comment.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import React, { ReactElement, StatelessComponent } from "react"; - -import { Flex } from "talk-ui/components"; - -import * as styles from "./Comment.css"; -import HTMLContent from "./HTMLContent"; -import Timestamp from "./Timestamp"; -import TopBar from "./TopBar"; -import Username from "./Username"; - -export interface CommentProps { - id: string; - className?: string; - author: { - username: string | null; - } | null; - body: string | null; - createdAt: string; - footer?: ReactElement | Array>; -} - -const Comment: StatelessComponent = props => { - return ( -
- - {props.author && - props.author.username && {props.author.username}} - {props.createdAt} - - {props.body || ""} - - {props.footer} - -
- ); -}; - -export default Comment; diff --git a/src/core/client/stream/components/Comment/HTMLContent.tsx b/src/core/client/stream/components/Comment/HTMLContent.tsx deleted file mode 100644 index b9d61c945..000000000 --- a/src/core/client/stream/components/Comment/HTMLContent.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import React from "react"; - -import dompurify from "dompurify"; -import styles from "./HTMLContent.css"; - -interface ContentProps { - children: string; -} - -class HTMLContent extends React.Component { - public render() { - const { children } = this.props; - return ( -
- ); - } -} - -export default HTMLContent; diff --git a/src/core/client/stream/components/Comment/__snapshots__/Comment.spec.tsx.snap b/src/core/client/stream/components/Comment/__snapshots__/Comment.spec.tsx.snap deleted file mode 100644 index 246444f0d..000000000 --- a/src/core/client/stream/components/Comment/__snapshots__/Comment.spec.tsx.snap +++ /dev/null @@ -1,27 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`renders username and body 1`] = ` -
- - - Marvin - - - 1995-12-17T03:24:00.000Z - - - - Woof - - -
-`; diff --git a/src/core/client/stream/components/Comment/index.ts b/src/core/client/stream/components/Comment/index.ts deleted file mode 100644 index abb1d79ae..000000000 --- a/src/core/client/stream/components/Comment/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default, default as IndentedComment } from "./IndentedComment"; diff --git a/src/core/client/stream/components/Comment/HTMLContent.css b/src/core/client/stream/components/HTMLContent.css similarity index 100% rename from src/core/client/stream/components/Comment/HTMLContent.css rename to src/core/client/stream/components/HTMLContent.css diff --git a/src/core/client/stream/components/Comment/HTMLContent.spec.tsx b/src/core/client/stream/components/HTMLContent.spec.tsx similarity index 100% rename from src/core/client/stream/components/Comment/HTMLContent.spec.tsx rename to src/core/client/stream/components/HTMLContent.spec.tsx diff --git a/src/core/client/stream/components/HTMLContent.tsx b/src/core/client/stream/components/HTMLContent.tsx new file mode 100644 index 000000000..62bd62b78 --- /dev/null +++ b/src/core/client/stream/components/HTMLContent.tsx @@ -0,0 +1,17 @@ +import dompurify from "dompurify"; +import React, { StatelessComponent } from "react"; + +import styles from "./HTMLContent.css"; + +interface HTMLContentProps { + children: string; +} + +const HTMLContent: StatelessComponent = ({ children }) => ( +
+); + +export default HTMLContent; diff --git a/src/core/client/stream/components/Comment/Timestamp.css b/src/core/client/stream/components/Timestamp.css similarity index 100% rename from src/core/client/stream/components/Comment/Timestamp.css rename to src/core/client/stream/components/Timestamp.css diff --git a/src/core/client/stream/components/Comment/Timestamp.spec.tsx b/src/core/client/stream/components/Timestamp.spec.tsx similarity index 100% rename from src/core/client/stream/components/Comment/Timestamp.spec.tsx rename to src/core/client/stream/components/Timestamp.spec.tsx diff --git a/src/core/client/stream/components/Comment/Timestamp.tsx b/src/core/client/stream/components/Timestamp.tsx similarity index 100% rename from src/core/client/stream/components/Comment/Timestamp.tsx rename to src/core/client/stream/components/Timestamp.tsx diff --git a/src/core/client/stream/components/UserBoxAuthenticated.tsx b/src/core/client/stream/components/UserBoxAuthenticated.tsx index 94ed90ac6..68c7fba00 100644 --- a/src/core/client/stream/components/UserBoxAuthenticated.tsx +++ b/src/core/client/stream/components/UserBoxAuthenticated.tsx @@ -20,7 +20,7 @@ const UserBoxAuthenticated: StatelessComponent< return ( } > @@ -28,7 +28,7 @@ const UserBoxAuthenticated: StatelessComponent< - + - + + + ) : ( + <> + + + + + + + + )} + + + + )} + + ); +}; + +export default EditCommentForm; diff --git a/src/core/client/stream/components/Indent.css b/src/core/client/stream/tabs/comments/components/Indent.css similarity index 100% rename from src/core/client/stream/components/Indent.css rename to src/core/client/stream/tabs/comments/components/Indent.css diff --git a/src/core/client/stream/components/Indent.spec.tsx b/src/core/client/stream/tabs/comments/components/Indent.spec.tsx similarity index 100% rename from src/core/client/stream/components/Indent.spec.tsx rename to src/core/client/stream/tabs/comments/components/Indent.spec.tsx diff --git a/src/core/client/stream/components/Indent.tsx b/src/core/client/stream/tabs/comments/components/Indent.tsx similarity index 100% rename from src/core/client/stream/components/Indent.tsx rename to src/core/client/stream/tabs/comments/components/Indent.tsx diff --git a/src/core/client/stream/components/PermalinkButton/PermalinkButton.css b/src/core/client/stream/tabs/comments/components/PermalinkButton/PermalinkButton.css similarity index 100% rename from src/core/client/stream/components/PermalinkButton/PermalinkButton.css rename to src/core/client/stream/tabs/comments/components/PermalinkButton/PermalinkButton.css diff --git a/src/core/client/stream/components/PermalinkButton/PermalinkButton.tsx b/src/core/client/stream/tabs/comments/components/PermalinkButton/PermalinkButton.tsx similarity index 100% rename from src/core/client/stream/components/PermalinkButton/PermalinkButton.tsx rename to src/core/client/stream/tabs/comments/components/PermalinkButton/PermalinkButton.tsx diff --git a/src/core/client/stream/components/PermalinkButton/PermalinkPopover.css b/src/core/client/stream/tabs/comments/components/PermalinkButton/PermalinkPopover.css similarity index 100% rename from src/core/client/stream/components/PermalinkButton/PermalinkPopover.css rename to src/core/client/stream/tabs/comments/components/PermalinkButton/PermalinkPopover.css diff --git a/src/core/client/stream/components/PermalinkButton/PermalinkPopover.tsx b/src/core/client/stream/tabs/comments/components/PermalinkButton/PermalinkPopover.tsx similarity index 100% rename from src/core/client/stream/components/PermalinkButton/PermalinkPopover.tsx rename to src/core/client/stream/tabs/comments/components/PermalinkButton/PermalinkPopover.tsx diff --git a/src/core/client/stream/components/PermalinkButton/index.ts b/src/core/client/stream/tabs/comments/components/PermalinkButton/index.ts similarity index 100% rename from src/core/client/stream/components/PermalinkButton/index.ts rename to src/core/client/stream/tabs/comments/components/PermalinkButton/index.ts diff --git a/src/core/client/stream/components/PermalinkView.css b/src/core/client/stream/tabs/comments/components/PermalinkView.css similarity index 100% rename from src/core/client/stream/components/PermalinkView.css rename to src/core/client/stream/tabs/comments/components/PermalinkView.css diff --git a/src/core/client/stream/components/PermalinkView.tsx b/src/core/client/stream/tabs/comments/components/PermalinkView.tsx similarity index 100% rename from src/core/client/stream/components/PermalinkView.tsx rename to src/core/client/stream/tabs/comments/components/PermalinkView.tsx diff --git a/src/core/client/stream/components/PostCommentForm.css b/src/core/client/stream/tabs/comments/components/PostCommentForm.css similarity index 100% rename from src/core/client/stream/components/PostCommentForm.css rename to src/core/client/stream/tabs/comments/components/PostCommentForm.css diff --git a/src/core/client/stream/components/PostCommentForm.tsx b/src/core/client/stream/tabs/comments/components/PostCommentForm.tsx similarity index 100% rename from src/core/client/stream/components/PostCommentForm.tsx rename to src/core/client/stream/tabs/comments/components/PostCommentForm.tsx diff --git a/src/core/client/stream/components/PostCommentFormFake.css b/src/core/client/stream/tabs/comments/components/PostCommentFormFake.css similarity index 100% rename from src/core/client/stream/components/PostCommentFormFake.css rename to src/core/client/stream/tabs/comments/components/PostCommentFormFake.css diff --git a/src/core/client/stream/components/PostCommentFormFake.spec.tsx b/src/core/client/stream/tabs/comments/components/PostCommentFormFake.spec.tsx similarity index 100% rename from src/core/client/stream/components/PostCommentFormFake.spec.tsx rename to src/core/client/stream/tabs/comments/components/PostCommentFormFake.spec.tsx diff --git a/src/core/client/stream/components/PostCommentFormFake.tsx b/src/core/client/stream/tabs/comments/components/PostCommentFormFake.tsx similarity index 100% rename from src/core/client/stream/components/PostCommentFormFake.tsx rename to src/core/client/stream/tabs/comments/components/PostCommentFormFake.tsx diff --git a/src/core/client/stream/components/PoweredBy.spec.tsx b/src/core/client/stream/tabs/comments/components/PoweredBy.spec.tsx similarity index 100% rename from src/core/client/stream/components/PoweredBy.spec.tsx rename to src/core/client/stream/tabs/comments/components/PoweredBy.spec.tsx diff --git a/src/core/client/stream/components/PoweredBy.tsx b/src/core/client/stream/tabs/comments/components/PoweredBy.tsx similarity index 82% rename from src/core/client/stream/components/PoweredBy.tsx rename to src/core/client/stream/tabs/comments/components/PoweredBy.tsx index 2f1c036eb..b1ca618c4 100644 --- a/src/core/client/stream/components/PoweredBy.tsx +++ b/src/core/client/stream/tabs/comments/components/PoweredBy.tsx @@ -14,11 +14,7 @@ const PoweredBy: StatelessComponent = props => { id="comments-poweredBy" logo={} > - + {"Powered by The Coral Project"} diff --git a/src/core/client/stream/components/RTE.css b/src/core/client/stream/tabs/comments/components/RTE.css similarity index 100% rename from src/core/client/stream/components/RTE.css rename to src/core/client/stream/tabs/comments/components/RTE.css diff --git a/src/core/client/stream/components/RTE.spec.tsx b/src/core/client/stream/tabs/comments/components/RTE.spec.tsx similarity index 100% rename from src/core/client/stream/components/RTE.spec.tsx rename to src/core/client/stream/tabs/comments/components/RTE.spec.tsx diff --git a/src/core/client/stream/components/RTE.tsx b/src/core/client/stream/tabs/comments/components/RTE.tsx similarity index 100% rename from src/core/client/stream/components/RTE.tsx rename to src/core/client/stream/tabs/comments/components/RTE.tsx diff --git a/src/core/client/stream/components/ReplyCommentForm.tsx b/src/core/client/stream/tabs/comments/components/ReplyCommentForm.tsx similarity index 100% rename from src/core/client/stream/components/ReplyCommentForm.tsx rename to src/core/client/stream/tabs/comments/components/ReplyCommentForm.tsx diff --git a/src/core/client/stream/components/ReplyList.spec.tsx b/src/core/client/stream/tabs/comments/components/ReplyList.spec.tsx similarity index 100% rename from src/core/client/stream/components/ReplyList.spec.tsx rename to src/core/client/stream/tabs/comments/components/ReplyList.spec.tsx diff --git a/src/core/client/stream/components/ReplyList.tsx b/src/core/client/stream/tabs/comments/components/ReplyList.tsx similarity index 100% rename from src/core/client/stream/components/ReplyList.tsx rename to src/core/client/stream/tabs/comments/components/ReplyList.tsx diff --git a/src/core/client/stream/components/Stream.css b/src/core/client/stream/tabs/comments/components/Stream.css similarity index 100% rename from src/core/client/stream/components/Stream.css rename to src/core/client/stream/tabs/comments/components/Stream.css diff --git a/src/core/client/stream/components/Stream.spec.tsx b/src/core/client/stream/tabs/comments/components/Stream.spec.tsx similarity index 100% rename from src/core/client/stream/components/Stream.spec.tsx rename to src/core/client/stream/tabs/comments/components/Stream.spec.tsx diff --git a/src/core/client/stream/components/Stream.tsx b/src/core/client/stream/tabs/comments/components/Stream.tsx similarity index 97% rename from src/core/client/stream/components/Stream.tsx rename to src/core/client/stream/tabs/comments/components/Stream.tsx index 1bed822ea..52c08e645 100644 --- a/src/core/client/stream/components/Stream.tsx +++ b/src/core/client/stream/tabs/comments/components/Stream.tsx @@ -3,12 +3,12 @@ import * as React from "react"; import { StatelessComponent } from "react"; import { PropTypesOf } from "talk-framework/types"; +import UserBoxContainer from "talk-stream/containers/UserBoxContainer"; import { Button, HorizontalGutter } from "talk-ui/components"; import CommentContainer from "../containers/CommentContainer"; import PostCommentFormContainer from "../containers/PostCommentFormContainer"; import ReplyListContainer from "../containers/ReplyListContainer"; -import UserBoxContainer from "../containers/UserBoxContainer"; import PostCommentFormFake from "./PostCommentFormFake"; import * as styles from "./Stream.css"; diff --git a/src/core/client/stream/tabs/comments/components/__snapshots__/CommentsPane.spec.tsx.snap b/src/core/client/stream/tabs/comments/components/__snapshots__/CommentsPane.spec.tsx.snap new file mode 100644 index 000000000..2207f2403 --- /dev/null +++ b/src/core/client/stream/tabs/comments/components/__snapshots__/CommentsPane.spec.tsx.snap @@ -0,0 +1,5 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`renders permalink view 1`] = ``; + +exports[`renders stream 1`] = ``; diff --git a/src/core/client/stream/components/__snapshots__/Indent.spec.tsx.snap b/src/core/client/stream/tabs/comments/components/__snapshots__/Indent.spec.tsx.snap similarity index 100% rename from src/core/client/stream/components/__snapshots__/Indent.spec.tsx.snap rename to src/core/client/stream/tabs/comments/components/__snapshots__/Indent.spec.tsx.snap diff --git a/src/core/client/stream/components/__snapshots__/PostCommentFormFake.spec.tsx.snap b/src/core/client/stream/tabs/comments/components/__snapshots__/PostCommentFormFake.spec.tsx.snap similarity index 100% rename from src/core/client/stream/components/__snapshots__/PostCommentFormFake.spec.tsx.snap rename to src/core/client/stream/tabs/comments/components/__snapshots__/PostCommentFormFake.spec.tsx.snap diff --git a/src/core/client/stream/components/__snapshots__/PoweredBy.spec.tsx.snap b/src/core/client/stream/tabs/comments/components/__snapshots__/PoweredBy.spec.tsx.snap similarity index 93% rename from src/core/client/stream/components/__snapshots__/PoweredBy.spec.tsx.snap rename to src/core/client/stream/tabs/comments/components/__snapshots__/PoweredBy.spec.tsx.snap index 14ae7599d..fdee2029d 100644 --- a/src/core/client/stream/components/__snapshots__/PoweredBy.spec.tsx.snap +++ b/src/core/client/stream/tabs/comments/components/__snapshots__/PoweredBy.spec.tsx.snap @@ -16,7 +16,7 @@ exports[`renders correctly 1`] = ` Powered by <logo>The Coral Project</logo> diff --git a/src/core/client/stream/components/__snapshots__/RTE.spec.tsx.snap b/src/core/client/stream/tabs/comments/components/__snapshots__/RTE.spec.tsx.snap similarity index 100% rename from src/core/client/stream/components/__snapshots__/RTE.spec.tsx.snap rename to src/core/client/stream/tabs/comments/components/__snapshots__/RTE.spec.tsx.snap diff --git a/src/core/client/stream/components/__snapshots__/ReplyList.spec.tsx.snap b/src/core/client/stream/tabs/comments/components/__snapshots__/ReplyList.spec.tsx.snap similarity index 100% rename from src/core/client/stream/components/__snapshots__/ReplyList.spec.tsx.snap rename to src/core/client/stream/tabs/comments/components/__snapshots__/ReplyList.spec.tsx.snap diff --git a/src/core/client/stream/components/__snapshots__/Stream.spec.tsx.snap b/src/core/client/stream/tabs/comments/components/__snapshots__/Stream.spec.tsx.snap similarity index 100% rename from src/core/client/stream/components/__snapshots__/Stream.spec.tsx.snap rename to src/core/client/stream/tabs/comments/components/__snapshots__/Stream.spec.tsx.snap diff --git a/src/core/client/stream/containers/CommentContainer.spec.tsx b/src/core/client/stream/tabs/comments/containers/CommentContainer.spec.tsx similarity index 84% rename from src/core/client/stream/containers/CommentContainer.spec.tsx rename to src/core/client/stream/tabs/comments/containers/CommentContainer.spec.tsx index bde3f9606..772a8df79 100644 --- a/src/core/client/stream/containers/CommentContainer.spec.tsx +++ b/src/core/client/stream/tabs/comments/containers/CommentContainer.spec.tsx @@ -19,10 +19,15 @@ it("renders username and body", () => { comment: { id: "comment-id", author: { + id: "author-id", username: "Marvin", }, body: "Woof", createdAt: "1995-12-17T03:24:00.000Z", + editing: { + edited: false, + editableUntil: "1995-12-17T03:24:30.000Z", + }, pending: false, }, indentLevel: 1, @@ -42,10 +47,15 @@ it("renders body only", () => { comment: { id: "comment-id", author: { + id: "author-id", username: null, }, body: "Woof", createdAt: "1995-12-17T03:24:00.000Z", + editing: { + edited: false, + editableUntil: "1995-12-17T03:24:30.000Z", + }, pending: false, }, indentLevel: 1, diff --git a/src/core/client/stream/containers/CommentContainer.tsx b/src/core/client/stream/tabs/comments/containers/CommentContainer.tsx similarity index 51% rename from src/core/client/stream/containers/CommentContainer.tsx rename to src/core/client/stream/tabs/comments/containers/CommentContainer.tsx index 15d9b3eda..1cd09fd2e 100644 --- a/src/core/client/stream/containers/CommentContainer.tsx +++ b/src/core/client/stream/tabs/comments/containers/CommentContainer.tsx @@ -1,6 +1,8 @@ +import { Localized } from "fluent-react/compat"; import React, { Component } from "react"; import { graphql } from "react-relay"; +import { isBeforeDate } from "talk-common/utils"; import withFragmentContainer from "talk-framework/lib/relay/withFragmentContainer"; import { PropTypesOf } from "talk-framework/types"; import { CommentContainer_asset as AssetData } from "talk-stream/__generated__/CommentContainer_asset.graphql"; @@ -11,10 +13,12 @@ import { withShowAuthPopupMutation, } from "talk-stream/mutations"; +import { Button } from "talk-ui/components"; import Comment from "../components/Comment"; import ReplyButton from "../components/Comment/ReplyButton"; -import ReplyCommentFormContainer from ".//ReplyCommentFormContainer"; +import EditCommentFormContainer from "./EditCommentFormContainer"; import PermalinkButtonContainer from "./PermalinkButtonContainer"; +import ReplyCommentFormContainer from "./ReplyCommentFormContainer"; interface InnerProps { me: MeData | null; @@ -26,13 +30,41 @@ interface InnerProps { interface State { showReplyDialog: boolean; + showEditDialog: boolean; + editable: boolean; } export class CommentContainer extends Component { + private uneditableTimer: any; + public state = { showReplyDialog: false, + showEditDialog: false, + editable: this.isEditable(), }; + constructor(props: InnerProps) { + super(props); + if (this.isEditable()) { + this.uneditableTimer = this.updateWhenNotEditable(); + } + } + + public componentWillUnmount() { + clearTimeout(this.uneditableTimer); + } + + private isEditable() { + const isMyComment = !!( + this.props.me && + this.props.comment.author && + this.props.me.id === this.props.comment.author.id + ); + return ( + isMyComment && isBeforeDate(this.props.comment.editing.editableUntil) + ); + } + private openReplyDialog = () => { if (this.props.me) { this.setState(state => ({ @@ -43,21 +75,72 @@ export class CommentContainer extends Component { } }; + private openEditDialog = () => { + if (this.props.me) { + this.setState(state => ({ + showEditDialog: true, + })); + } else { + this.props.showAuthPopup({ view: "SIGN_IN" }); + } + }; + + private closeEditDialog = () => { + this.setState(state => ({ + showEditDialog: false, + })); + }; + private closeReplyDialog = () => { this.setState(state => ({ showReplyDialog: false, })); }; + private updateWhenNotEditable() { + const ms = + new Date(this.props.comment.editing.editableUntil).getTime() - Date.now(); + if (ms > 0) { + return setTimeout(() => this.setState({ editable: false }), ms); + } + return; + } + public render() { - const { comment, asset, ...rest } = this.props; - const { showReplyDialog } = this.state; + const { comment, asset, indentLevel } = this.props; + const { showReplyDialog, showEditDialog, editable } = this.state; + if (showEditDialog) { + return ( + + ); + } return ( <> + + + )) || + undefined + } footer={ <> ({ me: graphql` fragment CommentContainer_me on User { - __typename + id } `, asset: graphql` @@ -97,12 +180,18 @@ const enhanced = withShowAuthPopupMutation( fragment CommentContainer_comment on Comment { id author { + id username } body createdAt + editing { + edited + editableUntil + } pending ...ReplyCommentFormContainer_comment + ...EditCommentFormContainer_comment } `, })(CommentContainer) diff --git a/src/core/client/stream/tabs/comments/containers/CommentsPaneContainer.tsx b/src/core/client/stream/tabs/comments/containers/CommentsPaneContainer.tsx new file mode 100644 index 000000000..c058adc02 --- /dev/null +++ b/src/core/client/stream/tabs/comments/containers/CommentsPaneContainer.tsx @@ -0,0 +1,27 @@ +import * as React from "react"; +import { StatelessComponent } from "react"; + +import { graphql, withLocalStateContainer } from "talk-framework/lib/relay"; +import { CommentsPaneContainerLocal as Local } from "talk-stream/__generated__/CommentsPaneContainerLocal.graphql"; + +import CommentsPane from "../components/CommentsPane"; + +interface InnerProps { + local: Local; +} + +const CommentsPaneContainer: StatelessComponent = ({ + local: { commentID }, +}) => { + return ; +}; + +const enhanced = withLocalStateContainer( + graphql` + fragment CommentsPaneContainerLocal on Local { + commentID + } + ` +)(CommentsPaneContainer); + +export default enhanced; diff --git a/src/core/client/stream/tabs/comments/containers/EditCommentFormContainer.tsx b/src/core/client/stream/tabs/comments/containers/EditCommentFormContainer.tsx new file mode 100644 index 000000000..68e0d0644 --- /dev/null +++ b/src/core/client/stream/tabs/comments/containers/EditCommentFormContainer.tsx @@ -0,0 +1,134 @@ +import { CoralRTE } from "@coralproject/rte"; +import React, { Component } from "react"; +import { graphql } from "react-relay"; + +import { isBeforeDate } from "talk-common/utils"; +import { withContext } from "talk-framework/lib/bootstrap"; +import { BadUserInputError } from "talk-framework/lib/errors"; +import { withFragmentContainer } from "talk-framework/lib/relay"; +import { PropTypesOf } from "talk-framework/types"; + +import { EditCommentFormContainer_comment as CommentData } from "talk-stream/__generated__/EditCommentFormContainer_comment.graphql"; +import { + EditCommentMutation, + withEditCommentMutation, +} from "talk-stream/mutations"; + +import EditCommentForm, { + EditCommentFormProps, +} from "../components/EditCommentForm"; + +interface InnerProps { + editComment: EditCommentMutation; + comment: CommentData; + onClose?: () => void; + autofocus: boolean; +} + +interface State { + initialValues?: EditCommentFormProps["initialValues"]; + initialized: boolean; + expired: boolean; +} + +export class EditCommentFormContainer extends Component { + private expiredTimer: any; + + public state: State = { + initialized: false, + expired: !isBeforeDate(this.props.comment.editing.editableUntil), + }; + + constructor(props: InnerProps) { + super(props); + this.expiredTimer = this.updateWhenExpired(); + } + + public componentWillUnmount() { + clearTimeout(this.expiredTimer); + } + + private updateWhenExpired() { + const ms = + new Date(this.props.comment.editing.editableUntil).getTime() - Date.now(); + if (ms > 0) { + return setTimeout(() => this.setState({ expired: true }), ms); + } + return; + } + + private handleRTERef = (rte: CoralRTE | null) => { + if (rte && this.props.autofocus) { + rte.focus(); + } + }; + + private handleOnCancelOrClose = () => { + if (this.props.onClose) { + this.props.onClose(); + } + }; + + private handleOnSubmit: EditCommentFormProps["onSubmit"] = async ( + input, + form + ) => { + try { + await this.props.editComment({ + commentID: this.props.comment.id, + body: input.body, + }); + if (this.props.onClose) { + this.props.onClose(); + } + } catch (error) { + if (error instanceof BadUserInputError) { + return error.invalidArgsLocalized; + } + // tslint:disable-next-line:no-console + console.error(error); + } + return undefined; + }; + + public render() { + return ( + + ); + } +} +const enhanced = withContext(({ sessionStorage, browserInfo }) => ({ + // Disable autofocus on ios and enable for the rest. + autofocus: !browserInfo.ios, +}))( + withEditCommentMutation( + withFragmentContainer({ + comment: graphql` + fragment EditCommentFormContainer_comment on Comment { + id + body + createdAt + author { + username + } + editing { + editableUntil + } + } + `, + })(EditCommentFormContainer) + ) +); +export type PostCommentFormContainerProps = PropTypesOf; +export default enhanced; diff --git a/src/core/client/stream/containers/PermalinkButtonContainer.tsx b/src/core/client/stream/tabs/comments/containers/PermalinkButtonContainer.tsx similarity index 100% rename from src/core/client/stream/containers/PermalinkButtonContainer.tsx rename to src/core/client/stream/tabs/comments/containers/PermalinkButtonContainer.tsx diff --git a/src/core/client/stream/containers/PermalinkViewContainer.tsx b/src/core/client/stream/tabs/comments/containers/PermalinkViewContainer.tsx similarity index 100% rename from src/core/client/stream/containers/PermalinkViewContainer.tsx rename to src/core/client/stream/tabs/comments/containers/PermalinkViewContainer.tsx diff --git a/src/core/client/stream/containers/PostCommentFormContainer.spec.tsx b/src/core/client/stream/tabs/comments/containers/PostCommentFormContainer.spec.tsx similarity index 100% rename from src/core/client/stream/containers/PostCommentFormContainer.spec.tsx rename to src/core/client/stream/tabs/comments/containers/PostCommentFormContainer.spec.tsx diff --git a/src/core/client/stream/containers/PostCommentFormContainer.tsx b/src/core/client/stream/tabs/comments/containers/PostCommentFormContainer.tsx similarity index 96% rename from src/core/client/stream/containers/PostCommentFormContainer.tsx rename to src/core/client/stream/tabs/comments/containers/PostCommentFormContainer.tsx index 882a3a07c..17e5f65c5 100644 --- a/src/core/client/stream/containers/PostCommentFormContainer.tsx +++ b/src/core/client/stream/tabs/comments/containers/PostCommentFormContainer.tsx @@ -5,10 +5,13 @@ import { BadUserInputError } from "talk-framework/lib/errors"; import { PromisifiedStorage } from "talk-framework/lib/storage"; import { PropTypesOf } from "talk-framework/types"; +import { + CreateCommentMutation, + withCreateCommentMutation, +} from "talk-stream/mutations"; import PostCommentForm, { PostCommentFormProps, } from "../components/PostCommentForm"; -import { CreateCommentMutation, withCreateCommentMutation } from "../mutations"; interface InnerProps { createComment: CreateCommentMutation; diff --git a/src/core/client/stream/containers/ReplyCommentFormContainer.spec.tsx b/src/core/client/stream/tabs/comments/containers/ReplyCommentFormContainer.spec.tsx similarity index 100% rename from src/core/client/stream/containers/ReplyCommentFormContainer.spec.tsx rename to src/core/client/stream/tabs/comments/containers/ReplyCommentFormContainer.spec.tsx diff --git a/src/core/client/stream/containers/ReplyCommentFormContainer.tsx b/src/core/client/stream/tabs/comments/containers/ReplyCommentFormContainer.tsx similarity index 97% rename from src/core/client/stream/containers/ReplyCommentFormContainer.tsx rename to src/core/client/stream/tabs/comments/containers/ReplyCommentFormContainer.tsx index 3c0379e30..6a724b82e 100644 --- a/src/core/client/stream/containers/ReplyCommentFormContainer.tsx +++ b/src/core/client/stream/tabs/comments/containers/ReplyCommentFormContainer.tsx @@ -9,11 +9,14 @@ import { PromisifiedStorage } from "talk-framework/lib/storage"; import { PropTypesOf } from "talk-framework/types"; import { ReplyCommentFormContainer_asset as AssetData } from "talk-stream/__generated__/ReplyCommentFormContainer_asset.graphql"; import { ReplyCommentFormContainer_comment as CommentData } from "talk-stream/__generated__/ReplyCommentFormContainer_comment.graphql"; +import { + CreateCommentMutation, + withCreateCommentMutation, +} from "talk-stream/mutations"; import ReplyCommentForm, { ReplyCommentFormProps, } from "../components/ReplyCommentForm"; -import { CreateCommentMutation, withCreateCommentMutation } from "../mutations"; interface InnerProps { createComment: CreateCommentMutation; diff --git a/src/core/client/stream/containers/ReplyListContainer.spec.tsx b/src/core/client/stream/tabs/comments/containers/ReplyListContainer.spec.tsx similarity index 100% rename from src/core/client/stream/containers/ReplyListContainer.spec.tsx rename to src/core/client/stream/tabs/comments/containers/ReplyListContainer.spec.tsx diff --git a/src/core/client/stream/containers/ReplyListContainer.tsx b/src/core/client/stream/tabs/comments/containers/ReplyListContainer.tsx similarity index 100% rename from src/core/client/stream/containers/ReplyListContainer.tsx rename to src/core/client/stream/tabs/comments/containers/ReplyListContainer.tsx diff --git a/src/core/client/stream/containers/StreamContainer.spec.tsx b/src/core/client/stream/tabs/comments/containers/StreamContainer.spec.tsx similarity index 100% rename from src/core/client/stream/containers/StreamContainer.spec.tsx rename to src/core/client/stream/tabs/comments/containers/StreamContainer.spec.tsx diff --git a/src/core/client/stream/containers/StreamContainer.tsx b/src/core/client/stream/tabs/comments/containers/StreamContainer.tsx similarity index 100% rename from src/core/client/stream/containers/StreamContainer.tsx rename to src/core/client/stream/tabs/comments/containers/StreamContainer.tsx diff --git a/src/core/client/stream/containers/__snapshots__/CommentContainer.spec.tsx.snap b/src/core/client/stream/tabs/comments/containers/__snapshots__/CommentContainer.spec.tsx.snap similarity index 87% rename from src/core/client/stream/containers/__snapshots__/CommentContainer.spec.tsx.snap rename to src/core/client/stream/tabs/comments/containers/__snapshots__/CommentContainer.spec.tsx.snap index 6f2005410..f6e7f50ba 100644 --- a/src/core/client/stream/containers/__snapshots__/CommentContainer.spec.tsx.snap +++ b/src/core/client/stream/tabs/comments/containers/__snapshots__/CommentContainer.spec.tsx.snap @@ -5,6 +5,7 @@ exports[`renders body only 1`] = ` } - id="comment-id" indentLevel={1} - me={null} - pending={false} - showAuthPopup={[Function]} + showEditedMarker={false} /> `; @@ -37,6 +35,7 @@ exports[`renders username and body 1`] = ` } - id="comment-id" indentLevel={1} - me={null} - pending={false} - showAuthPopup={[Function]} + showEditedMarker={false} /> `; diff --git a/src/core/client/stream/containers/__snapshots__/PostCommentFormContainer.spec.tsx.snap b/src/core/client/stream/tabs/comments/containers/__snapshots__/PostCommentFormContainer.spec.tsx.snap similarity index 100% rename from src/core/client/stream/containers/__snapshots__/PostCommentFormContainer.spec.tsx.snap rename to src/core/client/stream/tabs/comments/containers/__snapshots__/PostCommentFormContainer.spec.tsx.snap diff --git a/src/core/client/stream/containers/__snapshots__/ReplyCommentFormContainer.spec.tsx.snap b/src/core/client/stream/tabs/comments/containers/__snapshots__/ReplyCommentFormContainer.spec.tsx.snap similarity index 100% rename from src/core/client/stream/containers/__snapshots__/ReplyCommentFormContainer.spec.tsx.snap rename to src/core/client/stream/tabs/comments/containers/__snapshots__/ReplyCommentFormContainer.spec.tsx.snap diff --git a/src/core/client/stream/containers/__snapshots__/ReplyListContainer.spec.tsx.snap b/src/core/client/stream/tabs/comments/containers/__snapshots__/ReplyListContainer.spec.tsx.snap similarity index 100% rename from src/core/client/stream/containers/__snapshots__/ReplyListContainer.spec.tsx.snap rename to src/core/client/stream/tabs/comments/containers/__snapshots__/ReplyListContainer.spec.tsx.snap diff --git a/src/core/client/stream/containers/__snapshots__/StreamContainer.spec.tsx.snap b/src/core/client/stream/tabs/comments/containers/__snapshots__/StreamContainer.spec.tsx.snap similarity index 100% rename from src/core/client/stream/containers/__snapshots__/StreamContainer.spec.tsx.snap rename to src/core/client/stream/tabs/comments/containers/__snapshots__/StreamContainer.spec.tsx.snap diff --git a/src/core/client/stream/queries/PermalinkViewQuery.spec.tsx b/src/core/client/stream/tabs/comments/queries/PermalinkViewQuery.spec.tsx similarity index 100% rename from src/core/client/stream/queries/PermalinkViewQuery.spec.tsx rename to src/core/client/stream/tabs/comments/queries/PermalinkViewQuery.spec.tsx diff --git a/src/core/client/stream/queries/PermalinkViewQuery.tsx b/src/core/client/stream/tabs/comments/queries/PermalinkViewQuery.tsx similarity index 80% rename from src/core/client/stream/queries/PermalinkViewQuery.tsx rename to src/core/client/stream/tabs/comments/queries/PermalinkViewQuery.tsx index fc098dafe..4d1ac4611 100644 --- a/src/core/client/stream/queries/PermalinkViewQuery.tsx +++ b/src/core/client/stream/tabs/comments/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.spec.tsx b/src/core/client/stream/tabs/comments/queries/StreamQuery.spec.tsx similarity index 100% rename from src/core/client/stream/queries/StreamQuery.spec.tsx rename to src/core/client/stream/tabs/comments/queries/StreamQuery.spec.tsx diff --git a/src/core/client/stream/queries/StreamQuery.tsx b/src/core/client/stream/tabs/comments/queries/StreamQuery.tsx similarity index 80% rename from src/core/client/stream/queries/StreamQuery.tsx rename to src/core/client/stream/tabs/comments/queries/StreamQuery.tsx index b0f1f0c3f..ec61ec723 100644 --- a/src/core/client/stream/queries/StreamQuery.tsx +++ b/src/core/client/stream/tabs/comments/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/queries/__snapshots__/PermalinkViewQuery.spec.tsx.snap b/src/core/client/stream/tabs/comments/queries/__snapshots__/PermalinkViewQuery.spec.tsx.snap similarity index 100% rename from src/core/client/stream/queries/__snapshots__/PermalinkViewQuery.spec.tsx.snap rename to src/core/client/stream/tabs/comments/queries/__snapshots__/PermalinkViewQuery.spec.tsx.snap diff --git a/src/core/client/stream/queries/__snapshots__/StreamQuery.spec.tsx.snap b/src/core/client/stream/tabs/comments/queries/__snapshots__/StreamQuery.spec.tsx.snap similarity index 100% rename from src/core/client/stream/queries/__snapshots__/StreamQuery.spec.tsx.snap rename to src/core/client/stream/tabs/comments/queries/__snapshots__/StreamQuery.spec.tsx.snap diff --git a/src/core/client/stream/test/comments/__snapshots__/editComment.spec.tsx.snap b/src/core/client/stream/test/comments/__snapshots__/editComment.spec.tsx.snap new file mode 100644 index 000000000..6a91b49e0 --- /dev/null +++ b/src/core/client/stream/test/comments/__snapshots__/editComment.spec.tsx.snap @@ -0,0 +1,2584 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`cancel edit: edit canceled 1`] = ` +
+
+
+
+
+
+ Signed in as + + Markus + + . +
+
+ + Not you?  + + +
+
+
+
+
+
+ +
+
+
+ + + +
+ +
+
+
+
+
+
+ + Powered by + + ⁨The Coral Project⁩ + + +
+ +
+
+ +
+
+
+
+
+
+
+ + Markus + +
+ +
+
+
+ +
+
+
+
+ +
+
+
+
+
+
+
+
+
+ + Lukas + +
+ +
+
+
+
+
+ +
+
+
+
+
+
+
+`; + +exports[`edit a comment: edit form 1`] = ` +
+
+
+
+
+
+ Signed in as + + Markus + + . +
+
+ + Not you?  + + +
+
+
+
+
+
+ +
+
+
+ + + +
+ +
+
+
+
+
+
+ + Powered by + + ⁨The Coral Project⁩ + + +
+ +
+
+ +
+
+
+
+
+
+
+ + Markus + + +
+
+
+ +
+
+
+ + + +
+
+
+
+
+
+ + + Edit: + + remaining + +
+
+ + +
+
+ +
+
+
+
+
+
+ + Lukas + +
+ +
+
+
+
+
+ +
+
+
+
+
+
+
+`; + +exports[`edit a comment: optimistic response 1`] = ` +
+
+
+
+
+
+ Signed in as + + Markus + + . +
+
+ + Not you?  + + +
+
+
+
+
+
+ +
+
+
+ + + +
+ +
+
+
+
+
+
+ + Powered by + + ⁨The Coral Project⁩ + + +
+ +
+
+ +
+
+
+
+
+
+
+ + Markus + + +
+
+
+ +
+
+
+ + + +
+
+
+
+
+
+ + + Edit: + + remaining + +
+
+ + +
+
+ +
+
+
+
+
+
+ + Lukas + +
+ +
+
+
+
+
+ +
+
+
+
+
+
+
+`; + +exports[`edit a comment: render stream 1`] = ` +
+
+
+
+
+
+ Signed in as + + Markus + + . +
+
+ + Not you?  + + +
+
+
+
+
+
+ +
+
+
+ + + +
+ +
+
+
+
+
+
+ + Powered by + + ⁨The Coral Project⁩ + + +
+ +
+
+ +
+
+
+
+
+
+
+ + Markus + +
+ +
+
+
+ +
+
+
+
+ +
+
+
+
+
+
+
+
+
+ + Lukas + +
+ +
+
+
+
+
+ +
+
+
+
+
+
+
+`; + +exports[`edit a comment: server response 1`] = ` +
+
+
+
+
+
+ Signed in as + + Markus + + . +
+
+ + Not you?  + + +
+
+
+
+
+
+ +
+
+
+ + + +
+ +
+
+
+
+
+
+ + Powered by + + ⁨The Coral Project⁩ + + +
+ +
+
+ +
+
+
+
+
+
+
+ + Markus + +
+ +
+ ( + + Edited + + ) +
+
+
+
+ +
+
+
+
+ +
+
+
+
+
+
+
+
+
+ + Lukas + +
+ +
+
+
+
+
+ +
+
+
+
+
+
+
+`; + +exports[`shows expiry message: edit form closed 1`] = ` +
+
+
+
+
+
+ Signed in as + + Markus + + . +
+
+ + Not you?  + + +
+
+
+
+
+
+ +
+
+
+ + + +
+ +
+
+
+
+
+
+ + Powered by + + ⁨The Coral Project⁩ + + +
+ +
+
+ +
+
+
+
+
+
+
+ + Markus + +
+ +
+
+
+ +
+
+
+
+ +
+
+
+
+
+
+
+
+
+ + Lukas + +
+ +
+
+
+
+
+ +
+
+
+
+
+
+
+`; + +exports[`shows expiry message: edit time expired 1`] = ` +
+
+
+
+
+
+ Signed in as + + Markus + + . +
+
+ + Not you?  + + +
+
+
+
+
+
+ +
+
+
+ + + +
+ +
+
+
+
+
+
+ + Powered by + + ⁨The Coral Project⁩ + + +
+ +
+
+ +
+
+
+
+
+
+
+ + Markus + + +
+
+
+ +
+
+
+ + + +
+
+
+
+
+
+ + Edit time has expired. You can no longer edit this comment. Why not post another one? +
+
+ +
+
+ +
+
+
+
+
+
+ + Lukas + +
+ +
+
+
+
+
+ +
+
+
+
+
+
+
+`; diff --git a/src/core/client/stream/test/__snapshots__/loadMore.spec.tsx.snap b/src/core/client/stream/test/comments/__snapshots__/loadMore.spec.tsx.snap similarity index 79% rename from src/core/client/stream/test/__snapshots__/loadMore.spec.tsx.snap rename to src/core/client/stream/test/comments/__snapshots__/loadMore.spec.tsx.snap index 1d82d7bb9..84c4e89de 100644 --- a/src/core/client/stream/test/__snapshots__/loadMore.spec.tsx.snap +++ b/src/core/client/stream/test/comments/__snapshots__/loadMore.spec.tsx.snap @@ -156,20 +156,28 @@ exports[`loads more comments 1`] = ` role="article" >
- - Markus - - + + Markus + +
+ +
+
- - Lukas - - + + Lukas + +
+ +
+
- - Isabelle - - + + Isabelle + +
+ +
+
- - Markus - - + + Markus + +
+ +
+
- - Lukas - - + + Lukas + +
+ +
+
- - Markus - - + + Markus + +
+ +
+
- - Markus - - + + Markus + +
+ +
+
- - Markus - - + + Markus + +
+ +
+
Powered by
- - Markus - - + + Markus + +
+ +
+
+
+ +
- - Markus - - + + Markus + +
+ +
+
- - Lukas - - + + Lukas + +
+ +
+
Powered by
- - Markus - - + + Markus + +
+ +
+
- - Markus - - + + Markus + +
+ +
+
- - Lukas - - + + Lukas + +
+ +
+
Powered by
- - Markus - - + + Markus + +
+ +
+
- - Lukas - - + + Lukas + +
+ +
+
Powered by
- - Markus - - + + Markus + +
+ +
+
- - Lukas - - + + Lukas + +
+ +
+
Powered by
- - Markus - - + + Markus + +
+ +
+
- - Markus - - + + Markus + +
+ +
+
+
+ +
- - Lukas - - + + Lukas + +
+ +
+
Powered by
- - Markus - - + + Markus + +
+ +
+
- - Markus - - + + Markus + +
+ +
+
- - Lukas - - + + Lukas + +
+ +
+
Powered by
- - Markus - - + + Markus + +
+ +
+
- - Lukas - - + + Lukas + +
+ +
+
- - Markus - - + + Markus + +
+ +
+
- - Markus - - + + Markus + +
+ +
+
- - Markus - - + + Markus + +
+ +
+
- - Lukas - - + + Lukas + +
+ +
+
- - Markus - - + + Markus + +
+ +
+
- - Lukas - - + + Lukas + +
+ +
+
- - Markus - - + + Markus + +
+ +
+
- - Lukas - - + + Lukas + +
+ +
+
- - Markus - - + + Markus + +
+ +
+
- - Lukas - - + + Lukas + +
+ +
+
- - Isabelle - - + + Isabelle + +
+ +
+
{ + if (params.initLocalState) { + localRecord.setValue("COMMENTS", "activeTab"); + params.initLocalState(localRecord, source, environment); + } + }, + }); +} diff --git a/src/core/client/stream/test/comments/editComment.spec.tsx b/src/core/client/stream/test/comments/editComment.spec.tsx new file mode 100644 index 000000000..733f30eb2 --- /dev/null +++ b/src/core/client/stream/test/comments/editComment.spec.tsx @@ -0,0 +1,135 @@ +import timekeeper from "timekeeper"; + +import { timeout } from "talk-common/utils"; +import { createSinonStub } from "talk-framework/testHelpers"; + +import { assets, users } from "../fixtures"; +import create from "./create"; + +function createTestRenderer() { + 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).returns(users[0]) + ), + }, + Mutation: { + editComment: createSinonStub( + s => s.throws(), + s => + s + .withArgs(undefined, { + input: { + commentID: assets[0].comments.edges[0].node.id, + body: "Edited!", + clientMutationId: "0", + }, + }) + .returns({ + // TODO: add a type assertion here to ensure that if the type changes, that the test will fail + comment: { + id: assets[0].comments.edges[0].node.id, + body: "Edited! (from server)", + editing: { + edited: true, + }, + }, + clientMutationId: "0", + }) + ), + }, + }; + + const { testRenderer } = create({ + // Set this to true, to see graphql responses. + logNetwork: false, + resolvers, + initLocalState: localRecord => { + localRecord.setValue(assets[0].id, "assetID"); + }, + }); + return testRenderer; +} + +afterAll(() => { + timekeeper.reset(); +}); + +it("edit a comment", async () => { + timekeeper.freeze(assets[0].comments.edges[0].node.createdAt); + const testRenderer = createTestRenderer(); + + // Wait for loading. + await timeout(); + expect(testRenderer.toJSON()).toMatchSnapshot("render stream"); + + // Open edit form. + testRenderer.root + .findByProps({ id: "comments-commentContainer-editButton-comment-0" }) + .props.onClick(); + expect(testRenderer.toJSON()).toMatchSnapshot("edit form"); + + testRenderer.root + .findByProps({ inputId: "comments-editCommentForm-rte-comment-0" }) + .props.onChange({ html: "Edited!" }); + + testRenderer.root + .findByProps({ id: "comments-editCommentForm-form-comment-0" }) + .props.onSubmit(); + + // Test optimistic response. + expect(testRenderer.toJSON()).toMatchSnapshot("optimistic response"); + + // Wait for loading. + await timeout(); + + // Test after server response. + expect(testRenderer.toJSON()).toMatchSnapshot("server response"); +}); + +it("cancel edit", async () => { + timekeeper.freeze(assets[0].comments.edges[0].node.createdAt); + const testRenderer = createTestRenderer(); + + await timeout(); + + // Open edit form. + testRenderer.root + .findByProps({ id: "comments-commentContainer-editButton-comment-0" }) + .props.onClick(); + + // Cacnel edit form. + testRenderer.root + .findByProps({ id: "comments-editCommentForm-cancelButton-comment-0" }) + .props.onClick(); + expect(testRenderer.toJSON()).toMatchSnapshot("edit canceled"); +}); + +it("shows expiry message", async () => { + timekeeper.freeze(assets[0].comments.edges[0].node.createdAt); + const testRenderer = createTestRenderer(); + + await timeout(); + jest.useFakeTimers(); + // Open edit form. + testRenderer.root + .findByProps({ id: "comments-commentContainer-editButton-comment-0" }) + .props.onClick(); + + timekeeper.reset(); + jest.runOnlyPendingTimers(); + + // Show edit time expired. + expect(testRenderer.toJSON()).toMatchSnapshot("edit time expired"); + + // Close edit form. + testRenderer.root + .findByProps({ id: "comments-editCommentForm-closeButton-comment-0" }) + .props.onClick(); + expect(testRenderer.toJSON()).toMatchSnapshot("edit form closed"); +}); diff --git a/src/core/client/stream/test/loadMore.spec.tsx b/src/core/client/stream/test/comments/loadMore.spec.tsx similarity index 97% rename from src/core/client/stream/test/loadMore.spec.tsx rename to src/core/client/stream/test/comments/loadMore.spec.tsx index ea72b5510..404d3c702 100644 --- a/src/core/client/stream/test/loadMore.spec.tsx +++ b/src/core/client/stream/test/comments/loadMore.spec.tsx @@ -3,8 +3,8 @@ import { ReactTestRenderer } from "react-test-renderer"; import { timeout } from "talk-common/utils"; import { createSinonStub } from "talk-framework/testHelpers"; +import { assets, comments } from "../fixtures"; import create from "./create"; -import { assets, comments } from "./fixtures"; let testRenderer: ReactTestRenderer; beforeEach(() => { diff --git a/src/core/client/stream/test/permalinkView.spec.tsx b/src/core/client/stream/test/comments/permalinkView.spec.tsx similarity index 97% rename from src/core/client/stream/test/permalinkView.spec.tsx rename to src/core/client/stream/test/comments/permalinkView.spec.tsx index 438ca2cea..0bbe3e260 100644 --- a/src/core/client/stream/test/permalinkView.spec.tsx +++ b/src/core/client/stream/test/comments/permalinkView.spec.tsx @@ -4,8 +4,8 @@ import sinon from "sinon"; import { timeout } from "talk-common/utils"; import { createSinonStub } from "talk-framework/testHelpers"; +import { assets, comments } from "../fixtures"; import create from "./create"; -import { assets, comments } from "./fixtures"; let testRenderer: ReactTestRenderer; beforeEach(() => { diff --git a/src/core/client/stream/test/permalinkViewAssetNotFound.spec.tsx b/src/core/client/stream/test/comments/permalinkViewAssetNotFound.spec.tsx similarity index 100% rename from src/core/client/stream/test/permalinkViewAssetNotFound.spec.tsx rename to src/core/client/stream/test/comments/permalinkViewAssetNotFound.spec.tsx diff --git a/src/core/client/stream/test/permalinkViewCommentNotFound.spec.tsx b/src/core/client/stream/test/comments/permalinkViewCommentNotFound.spec.tsx similarity index 97% rename from src/core/client/stream/test/permalinkViewCommentNotFound.spec.tsx rename to src/core/client/stream/test/comments/permalinkViewCommentNotFound.spec.tsx index 6a7279669..9c4517351 100644 --- a/src/core/client/stream/test/permalinkViewCommentNotFound.spec.tsx +++ b/src/core/client/stream/test/comments/permalinkViewCommentNotFound.spec.tsx @@ -4,8 +4,8 @@ import sinon from "sinon"; import { timeout } from "talk-common/utils"; import { createSinonStub } from "talk-framework/testHelpers"; +import { assets, comments } from "../fixtures"; import create from "./create"; -import { assets, comments } from "./fixtures"; let testRenderer: ReactTestRenderer; beforeEach(() => { diff --git a/src/core/client/stream/test/postComment.spec.tsx b/src/core/client/stream/test/comments/postComment.spec.tsx similarity index 86% rename from src/core/client/stream/test/postComment.spec.tsx rename to src/core/client/stream/test/comments/postComment.spec.tsx index 658a9d036..01f296ffc 100644 --- a/src/core/client/stream/test/postComment.spec.tsx +++ b/src/core/client/stream/test/comments/postComment.spec.tsx @@ -4,21 +4,15 @@ import timekeeper from "timekeeper"; import { timeout } from "talk-common/utils"; import { createSinonStub } from "talk-framework/testHelpers"; +import { assets, users } from "../fixtures"; import create from "./create"; -import { assets, users } from "./fixtures"; 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( @@ -41,6 +35,10 @@ beforeEach(() => { author: users[0], body: "Hello world! (from server)", createdAt: "2018-07-06T18:24:00.000Z", + editing: { + edited: false, + editableUntil: "2018-07-06T18:24:30.000Z", + }, }, }, clientMutationId: "0", diff --git a/src/core/client/stream/test/postReply.spec.tsx b/src/core/client/stream/test/comments/postReply.spec.tsx similarity index 88% rename from src/core/client/stream/test/postReply.spec.tsx rename to src/core/client/stream/test/comments/postReply.spec.tsx index 3c64bc098..6f75b57a8 100644 --- a/src/core/client/stream/test/postReply.spec.tsx +++ b/src/core/client/stream/test/comments/postReply.spec.tsx @@ -4,21 +4,15 @@ import timekeeper from "timekeeper"; import { timeout } from "talk-common/utils"; import { createSinonStub } from "talk-framework/testHelpers"; +import { assets, users } from "../fixtures"; import create from "./create"; -import { assets, users } from "./fixtures"; 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( @@ -45,6 +39,10 @@ beforeEach(() => { edges: [], pageInfo: { endCursor: null, hasNextPage: false }, }, + editing: { + edited: false, + editableUntil: "2018-07-06T18:24:30.000Z", + }, }, }, clientMutationId: "0", diff --git a/src/core/client/stream/test/renderReplies.spec.tsx b/src/core/client/stream/test/comments/renderReplies.spec.tsx similarity index 94% rename from src/core/client/stream/test/renderReplies.spec.tsx rename to src/core/client/stream/test/comments/renderReplies.spec.tsx index b508d78d8..3c361740d 100644 --- a/src/core/client/stream/test/renderReplies.spec.tsx +++ b/src/core/client/stream/test/comments/renderReplies.spec.tsx @@ -3,8 +3,8 @@ import { ReactTestRenderer } from "react-test-renderer"; import { timeout } from "talk-common/utils"; import { createSinonStub } from "talk-framework/testHelpers"; +import { assetWithReplies } from "../fixtures"; import create from "./create"; -import { assetWithReplies } from "./fixtures"; let testRenderer: ReactTestRenderer; beforeEach(() => { diff --git a/src/core/client/stream/test/renderStream.spec.tsx b/src/core/client/stream/test/comments/renderStream.spec.tsx similarity index 95% rename from src/core/client/stream/test/renderStream.spec.tsx rename to src/core/client/stream/test/comments/renderStream.spec.tsx index 131bb678c..2778acb02 100644 --- a/src/core/client/stream/test/renderStream.spec.tsx +++ b/src/core/client/stream/test/comments/renderStream.spec.tsx @@ -3,8 +3,8 @@ import { ReactTestRenderer } from "react-test-renderer"; import { timeout } from "talk-common/utils"; import { createSinonStub } from "talk-framework/testHelpers"; +import { assets } from "../fixtures"; import create from "./create"; -import { assets } from "./fixtures"; let testRenderer: ReactTestRenderer; beforeEach(() => { diff --git a/src/core/client/stream/test/showAllReplies.spec.tsx b/src/core/client/stream/test/comments/showAllReplies.spec.tsx similarity index 98% rename from src/core/client/stream/test/showAllReplies.spec.tsx rename to src/core/client/stream/test/comments/showAllReplies.spec.tsx index 52d302e55..547ac7b33 100644 --- a/src/core/client/stream/test/showAllReplies.spec.tsx +++ b/src/core/client/stream/test/comments/showAllReplies.spec.tsx @@ -4,8 +4,8 @@ import sinon from "sinon"; import { timeout } from "talk-common/utils"; import { createSinonStub } from "talk-framework/testHelpers"; +import { assets, comments } from "../fixtures"; import create from "./create"; -import { assets, comments } from "./fixtures"; let testRenderer: ReactTestRenderer; beforeEach(() => { diff --git a/src/core/client/stream/test/create.tsx b/src/core/client/stream/test/create.tsx index 49d885c66..f105162c4 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"; @@ -14,7 +16,7 @@ import createEnvironment from "./createEnvironment"; import createFluentBundle from "./createFluentBundle"; import createNodeMock from "./createNodeMock"; -interface CreateParams { +export interface CreateParams { logNetwork?: boolean; resolvers: IResolvers; initLocalState?: ( @@ -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/fixtures.ts b/src/core/client/stream/test/fixtures.ts index b4e207978..ec219fb67 100644 --- a/src/core/client/stream/test/fixtures.ts +++ b/src/core/client/stream/test/fixtures.ts @@ -20,6 +20,10 @@ export const comments = [ body: "Joining Too", createdAt: "2018-07-06T18:24:00.000Z", replies: { edges: [], pageInfo: { endCursor: null, hasNextPage: false } }, + editing: { + edited: false, + editableUntil: "2018-07-06T18:24:30.000Z", + }, }, { id: "comment-1", @@ -27,6 +31,10 @@ export const comments = [ body: "What's up?", createdAt: "2018-07-06T18:20:00.000Z", replies: { edges: [], pageInfo: { endCursor: null, hasNextPage: false } }, + editing: { + edited: false, + editableUntil: "2018-07-06T18:20:30.000Z", + }, }, { id: "comment-2", @@ -34,6 +42,10 @@ export const comments = [ body: "Hey!", createdAt: "2018-07-06T18:14:00.000Z", replies: { edges: [], pageInfo: { endCursor: null, hasNextPage: false } }, + editing: { + edited: false, + editableUntil: "2018-07-06T18:14:30.000Z", + }, }, ]; @@ -68,6 +80,10 @@ export const commentWithReplies = { hasNextPage: false, }, }, + editing: { + edited: false, + editableUntil: "2018-07-06T18:24:30.000Z", + }, }; export const assetWithReplies = { diff --git a/src/core/client/ui/components/FormField/__snapshots__/FormField.spec.tsx.snap b/src/core/client/ui/components/FormField/__snapshots__/FormField.spec.tsx.snap index 874adc015..011020568 100644 --- a/src/core/client/ui/components/FormField/__snapshots__/FormField.spec.tsx.snap +++ b/src/core/client/ui/components/FormField/__snapshots__/FormField.spec.tsx.snap @@ -18,7 +18,7 @@ exports[`works with multiple form components 1`] = ` Username

A unique identifier displayed on your comments. You may use “_” and “.”

diff --git a/src/core/client/ui/components/InputDescription/InputDescription.tsx b/src/core/client/ui/components/InputDescription/InputDescription.tsx index d36d5fe7a..ae84ed141 100644 --- a/src/core/client/ui/components/InputDescription/InputDescription.tsx +++ b/src/core/client/ui/components/InputDescription/InputDescription.tsx @@ -11,7 +11,7 @@ const InputDescription: StatelessComponent = props => { const { className, children, ...rest } = props; return ( Form Components should go here

diff --git a/src/core/client/ui/components/ValidationMessage/ValidationMessage.css b/src/core/client/ui/components/Message/Message.css similarity index 75% rename from src/core/client/ui/components/ValidationMessage/ValidationMessage.css rename to src/core/client/ui/components/Message/Message.css index b3d6a6290..5811ad377 100644 --- a/src/core/client/ui/components/ValidationMessage/ValidationMessage.css +++ b/src/core/client/ui/components/Message/Message.css @@ -7,8 +7,15 @@ padding: calc(0.5 * var(--spacing-unit)) var(--spacing-unit); box-sizing: border-box; border-radius: var(--round-corners); + border-width: 1px; + border-style: solid; border-left-width: calc(0.5 * var(--spacing-unit)); - border-left-style: solid; +} + +.colorGrey { + background-color: var(--palette-common-white); + border-color: var(--palette-grey-main); + color: var(--palette-grey-main); } .colorError { @@ -21,7 +28,3 @@ display: flex; width: 100%; } - -.icon { - margin-right: var(--spacing-unit); -} diff --git a/src/core/client/ui/components/Message/Message.mdx b/src/core/client/ui/components/Message/Message.mdx new file mode 100644 index 000000000..828d18036 --- /dev/null +++ b/src/core/client/ui/components/Message/Message.mdx @@ -0,0 +1,28 @@ +--- +name: Message +menu: UI Kit +--- + +import { Playground } from 'docz' +import Message from './Message' +import MessageIcon from './MessageIcon' +import HorizontalGutter from '../HorizontalGutter' + +# Message + +## Basic Use + + + This is a message + Contrary to popular belief, Lorem Ipsum is not simply random text. + + + +## Usage with icon + + + alarmEdit: 1 min 23 secs Remaining + + + + diff --git a/src/core/client/ui/components/Message/Message.spec.tsx b/src/core/client/ui/components/Message/Message.spec.tsx new file mode 100644 index 000000000..ac69cea85 --- /dev/null +++ b/src/core/client/ui/components/Message/Message.spec.tsx @@ -0,0 +1,25 @@ +import React from "react"; +import TestRenderer from "react-test-renderer"; + +import { PropTypesOf } from "talk-ui/types"; + +import Message from "./Message"; +import MessageIcon from "./MessageIcon"; + +it("renders correctly", () => { + const props: PropTypesOf = { + className: "custom", + children: "Hello World", + }; + const renderer = TestRenderer.create(); + expect(renderer.toJSON()).toMatchSnapshot(); +}); + +it("renders icon", () => { + const renderer = TestRenderer.create( + + alertAlert Message + + ); + expect(renderer.toJSON()).toMatchSnapshot(); +}); diff --git a/src/core/client/ui/components/Message/Message.tsx b/src/core/client/ui/components/Message/Message.tsx new file mode 100644 index 000000000..7547d8442 --- /dev/null +++ b/src/core/client/ui/components/Message/Message.tsx @@ -0,0 +1,55 @@ +import cn from "classnames"; +import React, { ReactNode, StatelessComponent } from "react"; +import { withStyles } from "talk-ui/hocs"; +import * as styles from "./Message.css"; + +export interface MessageProps { + /** + * The content of the component. + */ + children: ReactNode; + /** + * Convenient prop to override the root styling. + */ + className?: string; + /** + * Override or extend the styles applied to the component. + */ + classes: typeof styles; + /* + * If set renders a full width message + */ + fullWidth?: boolean; + /* + * Name of color, "grey" stays by default - common gray one + */ + color?: "error" | "grey"; +} + +const Message: StatelessComponent = props => { + const { className, classes, fullWidth, children, color, ...rest } = props; + + const rootClassName = cn( + classes.root, + { + [classes.colorGrey]: color === "grey", + [classes.colorError]: color === "error", + [classes.fullWidth]: fullWidth, + }, + className + ); + + return ( +
+ {children} +
+ ); +}; + +Message.defaultProps = { + color: "grey", + fullWidth: false, +}; + +const enhanced = withStyles(styles)(Message); +export default enhanced; diff --git a/src/core/client/ui/components/Message/MessageIcon.css b/src/core/client/ui/components/Message/MessageIcon.css new file mode 100644 index 000000000..0104b6cd1 --- /dev/null +++ b/src/core/client/ui/components/Message/MessageIcon.css @@ -0,0 +1,7 @@ +.root { + align-self: flex-start; + margin-top: 1px; + &:first-child { + margin-right: calc(0.5 * var(--spacing-unit)); + } +} diff --git a/src/core/client/ui/components/Message/MessageIcon.tsx b/src/core/client/ui/components/Message/MessageIcon.tsx new file mode 100644 index 000000000..cd4d0ad13 --- /dev/null +++ b/src/core/client/ui/components/Message/MessageIcon.tsx @@ -0,0 +1,36 @@ +import cn from "classnames"; +import React, { HTMLAttributes, Ref, StatelessComponent } from "react"; + +import Icon, { IconProps } from "talk-ui/components/Icon"; +import { withForwardRef, withStyles } from "talk-ui/hocs"; + +import * as styles from "./MessageIcon.css"; + +interface InnerProps extends HTMLAttributes { + /** + * This prop can be used to add custom classnames. + * It is handled by the `withStyles `HOC. + */ + classes: typeof styles & IconProps["classes"]; + + size?: IconProps["size"]; + + /** The name of the icon to render */ + children: string; + + /** Internal: Forwarded Ref */ + forwardRef?: Ref; +} + +export const MessageIcon: StatelessComponent = props => { + const { classes, className, forwardRef, ...rest } = props; + const rootClassName = cn(classes.root, className); + return ; +}; + +MessageIcon.defaultProps = { + size: "sm", +}; + +const enhanced = withForwardRef(withStyles(styles)(MessageIcon)); +export default enhanced; diff --git a/src/core/client/ui/components/Message/__snapshots__/Message.spec.tsx.snap b/src/core/client/ui/components/Message/__snapshots__/Message.spec.tsx.snap new file mode 100644 index 000000000..38381f0c6 --- /dev/null +++ b/src/core/client/ui/components/Message/__snapshots__/Message.spec.tsx.snap @@ -0,0 +1,23 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`renders correctly 1`] = ` +
+ Hello World +
+`; + +exports[`renders icon 1`] = ` +
+ + Alert Message +
+`; diff --git a/src/core/client/ui/components/Message/index.ts b/src/core/client/ui/components/Message/index.ts new file mode 100644 index 000000000..84f6b9009 --- /dev/null +++ b/src/core/client/ui/components/Message/index.ts @@ -0,0 +1,2 @@ +export { default, default as Message } from "./Message"; +export { default as MessageIcon } from "./MessageIcon"; diff --git a/src/core/client/ui/components/RelativeTime/RelativeTime.css b/src/core/client/ui/components/RelativeTime/RelativeTime.css index e0450679f..c3a2af639 100644 --- a/src/core/client/ui/components/RelativeTime/RelativeTime.css +++ b/src/core/client/ui/components/RelativeTime/RelativeTime.css @@ -1,4 +1,2 @@ .root { - composes: bodyCopy from "talk-ui/shared/typography.css"; - background-color: transparent; } diff --git a/src/core/client/ui/components/Tabs/Tab.css b/src/core/client/ui/components/Tabs/Tab.css new file mode 100644 index 000000000..ead948a36 --- /dev/null +++ b/src/core/client/ui/components/Tabs/Tab.css @@ -0,0 +1,54 @@ +.root { + display: inline-block; + list-style: none; + margin-right: -1px; + margin-bottom: -1px; +} + +.button { + height: 100%; + box-sizing: border-box; + border-bottom: 0; + list-style: none; + padding: var(--spacing-unit); + font-weight: var(--font-weight-regular); + font-family: var(--font-family-serif); + + &:hover { + cursor: pointer; + } +} + +.root:first-child .primary { + border-top-left-radius: var(--round-corners); +} + +.root:last-child .primary { + border-top-right-radius: var(--round-corners); +} + +.primary { + position: relative; + background: var(--palette-grey-lightest); + color: var(--palette-grey-main); + border: 1px solid var(--palette-grey-lighter); + padding: calc(0.5 * var(--spacing-unit)) calc(var(--spacing-unit) * 2); + &.active { + background-color: var(--palette-common-white); + color: var(--palette-common-black); + border-bottom: 0; + border-top-width: calc(0.5 * var(--spacing-unit)); + border-top-color: var(--palette-primary-main); + border-radius: 0; + z-index: 10; + } +} + +.secondary { + padding: calc(0.5 * var(--spacing-unit)) calc(var(--spacing-unit) * 2); + + &.active { + font-weight: var(--font-weight-medium); + border-bottom: 3px solid var(--palette-primary-main); + } +} diff --git a/src/core/client/ui/components/Tabs/Tab.spec.tsx b/src/core/client/ui/components/Tabs/Tab.spec.tsx new file mode 100644 index 000000000..3af6a904a --- /dev/null +++ b/src/core/client/ui/components/Tabs/Tab.spec.tsx @@ -0,0 +1,9 @@ +import React from "react"; +import TestRenderer from "react-test-renderer"; + +import Tab from "./Tab"; + +it("renders correctly", () => { + const renderer = TestRenderer.create(Three); + expect(renderer.toJSON()).toMatchSnapshot(); +}); diff --git a/src/core/client/ui/components/Tabs/Tab.tsx b/src/core/client/ui/components/Tabs/Tab.tsx new file mode 100644 index 000000000..fd7e99e0f --- /dev/null +++ b/src/core/client/ui/components/Tabs/Tab.tsx @@ -0,0 +1,76 @@ +import cn from "classnames"; +import React from "react"; +import { withStyles } from "talk-ui/hocs"; +import BaseButton from "../BaseButton"; +import * as styles from "./Tab.css"; + +export interface TabProps { + /** + * Convenient prop to override the root styling. + */ + className?: string; + /** + * Override or extend the styles applied to the component. + */ + classes: typeof styles; + /** + * The id/name of the tab + */ + tabId: string; + /** + * Active status + */ + active?: boolean; + /** + * Style variant + */ + variant?: "primary" | "secondary"; + /** + * Action taken on tab click + */ + onTabClick?: (tabId: string) => void; +} + +class Tab extends React.Component { + public handleTabClick = () => { + if (this.props.onTabClick) { + this.props.onTabClick(this.props.tabId); + } + }; + + public render() { + const { className, classes, children, tabId, active, variant } = this.props; + + const buttonClassName = cn( + classes.button, + { + [classes.primary]: variant === "primary", + [classes.secondary]: variant === "secondary", + [classes.active]: active, + }, + className + ); + + return ( + + ); + } +} + +const enhanced = withStyles(styles)(Tab); +export default enhanced; diff --git a/src/core/client/ui/components/Tabs/TabBar.css b/src/core/client/ui/components/Tabs/TabBar.css new file mode 100644 index 000000000..0ba3a25f2 --- /dev/null +++ b/src/core/client/ui/components/Tabs/TabBar.css @@ -0,0 +1,13 @@ +.root { + display: flex; + padding: 0; + margin: 0; +} + +.primary { + border-bottom: 1px solid var(--palette-grey-lighter); +} + +.secondary { + border-bottom: 1px solid var(--palette-divider); +} diff --git a/src/core/client/ui/components/Tabs/TabBar.spec.tsx b/src/core/client/ui/components/Tabs/TabBar.spec.tsx new file mode 100644 index 000000000..ffe91d4fd --- /dev/null +++ b/src/core/client/ui/components/Tabs/TabBar.spec.tsx @@ -0,0 +1,34 @@ +import React from "react"; +import TestRenderer from "react-test-renderer"; + +import Tab from "./Tab"; +import TabBar from "./TabBar"; + +it("renders correctly", () => { + const renderer = TestRenderer.create( + + One + Two + Three + + ); + expect(renderer.toJSON()).toMatchSnapshot(); +}); + +it("sets initial tab as active", () => { + const renderer = TestRenderer.create( + + One + Two + Three + + ); + + const testInstance = renderer.root; + expect(testInstance.findByType(TabBar).props.activeTab).toBe("one"); + const tabs = testInstance.findAllByType(Tab); + expect(tabs.length).toBe(3); + expect(tabs[0].props.active).toBe(true); + expect(tabs[1].props.active).toBe(false); + expect(tabs[2].props.active).toBe(false); +}); diff --git a/src/core/client/ui/components/Tabs/TabBar.tsx b/src/core/client/ui/components/Tabs/TabBar.tsx new file mode 100644 index 000000000..29f993bf1 --- /dev/null +++ b/src/core/client/ui/components/Tabs/TabBar.tsx @@ -0,0 +1,80 @@ +import cn from "classnames"; +import React, { StatelessComponent } from "react"; +import { withStyles } from "talk-ui/hocs"; +import * as styles from "./TabBar.css"; + +export interface TabBarProps { + /** + * Convenient prop to override the root styling. + */ + className?: string; + /** + * Override or extend the styles applied to the component. + */ + classes: typeof styles; + /** + * Style variant + */ + variant?: "primary" | "secondary"; + /** + * Active tab id/name + */ + activeTab?: string; + /** + * Default active tab id/name + */ + defaultActiveTab?: string; + /** + * Action taken on tab click + */ + onTabClick?: (tabId: string) => void; +} + +const TabBar: StatelessComponent = props => { + const { + className, + classes, + children, + onTabClick, + activeTab, + variant, + defaultActiveTab, + } = props; + + const rootClassName = cn( + classes.root, + [ + { + [classes.primary]: variant === "primary", + [classes.secondary]: variant === "secondary", + }, + ], + className + ); + + const tabs = React.Children.toArray(children).map( + (child: React.ReactElement, index: number) => + React.cloneElement(child, { + tabId: child.props.tabId ? child.props.tabId : index, + active: + defaultActiveTab && !activeTab + ? child.props.tabId === defaultActiveTab + : child.props.tabId === activeTab, + variant, + onTabClick, + }) + ); + + return ( +
    + {tabs} +
+ ); +}; + +TabBar.defaultProps = { + variant: "primary", +}; + +const enhanced = withStyles(styles)(TabBar); +export default enhanced; diff --git a/src/core/client/ui/components/Tabs/TabContent.spec.tsx b/src/core/client/ui/components/Tabs/TabContent.spec.tsx new file mode 100644 index 000000000..f411b53f5 --- /dev/null +++ b/src/core/client/ui/components/Tabs/TabContent.spec.tsx @@ -0,0 +1,31 @@ +import React from "react"; +import TestRenderer from "react-test-renderer"; + +import TabContent from "./TabContent"; +import TabPane from "./TabPane"; + +it("renders correctly", () => { + const renderer = TestRenderer.create( + + Hola One + Hola Two + Hola Three + + ); + expect(renderer.toJSON()).toMatchSnapshot(); +}); + +it("sets initial tab as active, renders only one", () => { + const renderer = TestRenderer.create( + + Hola One + Hola Two + Hola Three + + ); + + const testInstance = renderer.root; + expect(testInstance.findByType(TabContent).props.activeTab).toBe("one"); + const panes = testInstance.findAllByType(TabPane); + expect(panes.length).toBe(1); +}); diff --git a/src/core/client/ui/components/Tabs/TabContent.tsx b/src/core/client/ui/components/Tabs/TabContent.tsx new file mode 100644 index 000000000..da68f05a9 --- /dev/null +++ b/src/core/client/ui/components/Tabs/TabContent.tsx @@ -0,0 +1,27 @@ +import React, { StatelessComponent } from "react"; + +export interface TabContentProps { + /** + * Active tab id/name + */ + activeTab?: string; +} + +const TabContent: StatelessComponent = props => { + const { children, activeTab } = props; + return ( + <> + {React.Children.toArray(children) + .filter( + (child: React.ReactElement) => child.props.tabId === activeTab + ) + .map((child: React.ReactElement, i) => + React.cloneElement(child, { + tabId: child.props.tabId ? child.props.tabId : i, + }) + )} + + ); +}; + +export default TabContent; diff --git a/src/core/client/ui/components/Tabs/TabPane.spec.tsx b/src/core/client/ui/components/Tabs/TabPane.spec.tsx new file mode 100644 index 000000000..595e054de --- /dev/null +++ b/src/core/client/ui/components/Tabs/TabPane.spec.tsx @@ -0,0 +1,9 @@ +import React from "react"; +import TestRenderer from "react-test-renderer"; + +import TabPane from "./TabPane"; + +it("renders correctly", () => { + const renderer = TestRenderer.create(Three); + expect(renderer.toJSON()).toMatchSnapshot(); +}); diff --git a/src/core/client/ui/components/Tabs/TabPane.tsx b/src/core/client/ui/components/Tabs/TabPane.tsx new file mode 100644 index 000000000..a3c84480c --- /dev/null +++ b/src/core/client/ui/components/Tabs/TabPane.tsx @@ -0,0 +1,29 @@ +import React, { StatelessComponent } from "react"; + +export interface TabBarProps { + /** + * Convenient prop to override the root styling. + */ + className?: string; + /** + * Name of the tab + */ + tabId: string; +} + +const TabPane: StatelessComponent = props => { + const { className, children, tabId } = props; + return ( +
+ {children} +
+ ); +}; + +export default TabPane; diff --git a/src/core/client/ui/components/Tabs/__snapshots__/Tab.spec.tsx.snap b/src/core/client/ui/components/Tabs/__snapshots__/Tab.spec.tsx.snap new file mode 100644 index 000000000..a30d114a8 --- /dev/null +++ b/src/core/client/ui/components/Tabs/__snapshots__/Tab.spec.tsx.snap @@ -0,0 +1,25 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`renders correctly 1`] = ` + +`; diff --git a/src/core/client/ui/components/Tabs/__snapshots__/TabBar.spec.tsx.snap b/src/core/client/ui/components/Tabs/__snapshots__/TabBar.spec.tsx.snap new file mode 100644 index 000000000..fd9f2ae4a --- /dev/null +++ b/src/core/client/ui/components/Tabs/__snapshots__/TabBar.spec.tsx.snap @@ -0,0 +1,75 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`renders correctly 1`] = ` +
    + + + +
+`; diff --git a/src/core/client/ui/components/Tabs/__snapshots__/TabContent.spec.tsx.snap b/src/core/client/ui/components/Tabs/__snapshots__/TabContent.spec.tsx.snap new file mode 100644 index 000000000..cdaf48d7d --- /dev/null +++ b/src/core/client/ui/components/Tabs/__snapshots__/TabContent.spec.tsx.snap @@ -0,0 +1,11 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`renders correctly 1`] = ` +
+ Hola One +
+`; diff --git a/src/core/client/ui/components/Tabs/__snapshots__/TabPane.spec.tsx.snap b/src/core/client/ui/components/Tabs/__snapshots__/TabPane.spec.tsx.snap new file mode 100644 index 000000000..ad884add2 --- /dev/null +++ b/src/core/client/ui/components/Tabs/__snapshots__/TabPane.spec.tsx.snap @@ -0,0 +1,11 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`renders correctly 1`] = ` +
+ Three +
+`; diff --git a/src/core/client/ui/components/Tabs/index.ts b/src/core/client/ui/components/Tabs/index.ts new file mode 100644 index 000000000..f8818e9ea --- /dev/null +++ b/src/core/client/ui/components/Tabs/index.ts @@ -0,0 +1,4 @@ +export { default as TabBar } from "./TabBar"; +export { default as Tab } from "./Tab"; +export { default as TabPane } from "./TabPane"; +export { default as TabContent } from "./TabContent"; diff --git a/src/core/client/ui/components/Tabs/tabs.mdx b/src/core/client/ui/components/Tabs/tabs.mdx new file mode 100644 index 000000000..f465209ae --- /dev/null +++ b/src/core/client/ui/components/Tabs/tabs.mdx @@ -0,0 +1,52 @@ +--- +name: Tabs +menu: UI Kit +--- + +# Tabs + +### Examples + +import { Playground } from 'docz' +import { Flex, TabBar, Tab, TabContent, TabPane } from './index' +import Container from "react-with-state-props" + +## Primary Tabs + + ( +
+ props.setActiveId(id)}> + One + Two + Three + + + Hola One + Hola Two + Hola Three + +
+ )}/> +
+ +## Secondary Tabs + + ( +
+ props.setActiveId(id)}> + One + Two + Three + + + Hola One + Hola Two + Hola Three + +
+ )}/> +
diff --git a/src/core/client/ui/components/Typography/Typography.css b/src/core/client/ui/components/Typography/Typography.css index a2bd115fe..be3e0954e 100644 --- a/src/core/client/ui/components/Typography/Typography.css +++ b/src/core/client/ui/components/Typography/Typography.css @@ -40,8 +40,8 @@ composes: inputLabel from "talk-ui/shared/typography.css"; } -.inputDescription { - composes: inputDescription from "talk-ui/shared/typography.css"; +.detail { + composes: detail from "talk-ui/shared/typography.css"; } .timestamp { diff --git a/src/core/client/ui/components/Typography/Typography.tsx b/src/core/client/ui/components/Typography/Typography.tsx index 2c4a20d94..b8f6d2396 100644 --- a/src/core/client/ui/components/Typography/Typography.tsx +++ b/src/core/client/ui/components/Typography/Typography.tsx @@ -15,7 +15,7 @@ type Variant = | "bodyCopy" | "bodyCopyBold" | "inputLabel" - | "inputDescription" + | "detail" | "timestamp"; // Based on Typography Component of Material UI. @@ -146,7 +146,7 @@ Typography.defaultProps = { bodyCopyBold: "p", timestamp: "span", inputLabel: "label", - inputDescription: "p", + detail: "p", }, noWrap: false, paragraph: false, diff --git a/src/core/client/ui/components/ValidationMessage/ValidationMessage.mdx b/src/core/client/ui/components/ValidationMessage/ValidationMessage.mdx index c8e7b11ad..28bbec5a6 100644 --- a/src/core/client/ui/components/ValidationMessage/ValidationMessage.mdx +++ b/src/core/client/ui/components/ValidationMessage/ValidationMessage.mdx @@ -4,7 +4,7 @@ menu: UI Kit --- import { Playground } from 'docz' -import ValidationMessage from './ValidationMessage.tsx' +import ValidationMessage from './ValidationMessage' import HorizontalGutter from '../HorizontalGutter' # ValidationMessage diff --git a/src/core/client/ui/components/ValidationMessage/ValidationMessage.tsx b/src/core/client/ui/components/ValidationMessage/ValidationMessage.tsx index 0cc781560..8d1343f8b 100644 --- a/src/core/client/ui/components/ValidationMessage/ValidationMessage.tsx +++ b/src/core/client/ui/components/ValidationMessage/ValidationMessage.tsx @@ -1,10 +1,6 @@ -import cn from "classnames"; import React, { ReactNode, StatelessComponent } from "react"; - -import { withStyles } from "talk-ui/hocs"; - -import Icon from "../Icon"; -import * as styles from "./ValidationMessage.css"; +import Message from "../Message"; +import MessageIcon from "../Message/MessageIcon"; export interface ValidationMessageProps { /** @@ -16,34 +12,24 @@ export interface ValidationMessageProps { */ className?: string; /** - * Override or extend the styles applied to the component. + * If set renders a full width message */ - classes: typeof styles; - /* - * If set renders a full width message - */ fullWidth?: boolean; } const ValidationMessage: StatelessComponent = props => { - const { className, classes, fullWidth, children, ...rest } = props; - - const rootClassName = cn( - classes.root, - classes.colorError, - { - [classes.fullWidth]: fullWidth, - }, - className - ); + const { className, fullWidth, children, ...rest } = props; return ( -
- - warning - + + warning {children} -
+
); }; @@ -51,5 +37,4 @@ ValidationMessage.defaultProps = { fullWidth: false, }; -const enhanced = withStyles(styles)(ValidationMessage); -export default enhanced; +export default ValidationMessage; diff --git a/src/core/client/ui/components/ValidationMessage/__snapshots__/ValidationMessage.spec.tsx.snap b/src/core/client/ui/components/ValidationMessage/__snapshots__/ValidationMessage.spec.tsx.snap index 7f03e37e0..c0e1861bb 100644 --- a/src/core/client/ui/components/ValidationMessage/__snapshots__/ValidationMessage.spec.tsx.snap +++ b/src/core/client/ui/components/ValidationMessage/__snapshots__/ValidationMessage.spec.tsx.snap @@ -2,11 +2,11 @@ exports[`renders correctly 1`] = `
diff --git a/src/core/client/ui/components/index.ts b/src/core/client/ui/components/index.ts index f701660dd..6477af50e 100644 --- a/src/core/client/ui/components/index.ts +++ b/src/core/client/ui/components/index.ts @@ -20,3 +20,5 @@ export { default as Spinner } from "./Spinner"; export { default as HorizontalGutter } from "./HorizontalGutter"; export { default as Icon } from "./Icon"; export { default as AriaInfo } from "./AriaInfo"; +export { default as Message, MessageIcon } from "./Message"; +export { Tab, TabBar, TabContent, TabPane } from "./Tabs"; diff --git a/src/core/client/ui/shared/typography.css b/src/core/client/ui/shared/typography.css index cb785f8fc..3012a3617 100644 --- a/src/core/client/ui/shared/typography.css +++ b/src/core/client/ui/shared/typography.css @@ -155,7 +155,7 @@ color: var(--palette-text-primary); } -.inputDescription { +.detail { font-size: calc(14rem / var(--rem-base)); font-weight: var(--font-weight-regular); font-family: var(--font-family-sans-serif); diff --git a/src/core/common/utils/index.ts b/src/core/common/utils/index.ts index 28435047e..4d6df9fc5 100644 --- a/src/core/common/utils/index.ts +++ b/src/core/common/utils/index.ts @@ -2,3 +2,4 @@ export { default as timeout } from "./timeout"; export { default as animationFrame } from "./animationFrame"; export { default as pascalCase } from "./pascalCase"; export { default as oncePerFrame } from "./oncePerFrame"; +export { default as isBeforeDate } from "./isBeforeDate"; diff --git a/src/core/common/utils/isBeforeDate.spec.ts b/src/core/common/utils/isBeforeDate.spec.ts new file mode 100644 index 000000000..4359ca55a --- /dev/null +++ b/src/core/common/utils/isBeforeDate.spec.ts @@ -0,0 +1,9 @@ +import timekeeper from "timekeeper"; +import isBeforeDate from "./isBeforeDate"; + +it("works correctly", () => { + timekeeper.freeze(new Date("2018-07-06T18:24:00.000Z")); + expect(isBeforeDate(new Date("2018-07-06T18:24:30.000Z"))).toBe(true); + expect(isBeforeDate(new Date("2018-07-06T18:23:30.000Z"))).toBe(false); + timekeeper.reset(); +}); diff --git a/src/core/common/utils/isBeforeDate.ts b/src/core/common/utils/isBeforeDate.ts new file mode 100644 index 000000000..24d2a33e9 --- /dev/null +++ b/src/core/common/utils/isBeforeDate.ts @@ -0,0 +1,3 @@ +export default function isBeforeDate(date: string | number | Date) { + return new Date() < new Date(date); +} diff --git a/src/core/server/graph/tenant/mutators/comment.ts b/src/core/server/graph/tenant/mutators/comment.ts index d0c26e431..aeaac70ee 100644 --- a/src/core/server/graph/tenant/mutators/comment.ts +++ b/src/core/server/graph/tenant/mutators/comment.ts @@ -26,7 +26,6 @@ export default (ctx: TenantContext) => ({ ctx.user!, { id: input.commentID, - asset_id: input.assetID, body: input.body, }, ctx.req diff --git a/src/core/server/graph/tenant/schema/schema.graphql b/src/core/server/graph/tenant/schema/schema.graphql index aab430f22..424a777e5 100644 --- a/src/core/server/graph/tenant/schema/schema.graphql +++ b/src/core/server/graph/tenant/schema/schema.graphql @@ -970,12 +970,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. @@ -1040,11 +1036,6 @@ type CreateCommentPayload { EditCommentInput provides the input for the editComment Mutation. """ input EditCommentInput { - """ - assetID is the ID of the Asset where we are editing a comment on. - """ - assetID: ID! - """ commentID is the ID of the comment being edited. """ diff --git a/src/core/server/models/comment.ts b/src/core/server/models/comment.ts index 91fa5c903..bea38ea20 100644 --- a/src/core/server/models/comment.ts +++ b/src/core/server/models/comment.ts @@ -104,7 +104,7 @@ export async function createComment( export type EditCommentInput = Pick< Comment, - "id" | "author_id" | "body" | "status" + "id" | "author_id" | "body" | "status" | "metadata" > & { /** * lastEditableCommentCreatedAt is the date that the last comment would have @@ -126,7 +126,16 @@ export async function editComment( ]; const createdAt = new Date(); - const { id, body, lastEditableCommentCreatedAt, status, author_id } = input; + const { + id, + body, + lastEditableCommentCreatedAt, + status, + author_id, + metadata, + } = input; + + // TODO: (wyattjoh) consider resetting the action counts if we're starting fresh with a new comment const result = await collection(db).findOneAndUpdate( { @@ -145,6 +154,10 @@ export async function editComment( $set: { body, status, + // Embed all the metadata properties, this may override the existing + // metadata, but we won't replace metadata that has been recalculated. + // TODO: (wyattjoh) consider if we want to replace the metadata for edited comments instead of supplementing it + ...dotize({ metadata }), }, $push: { body_history: { diff --git a/src/core/server/services/comments/index.ts b/src/core/server/services/comments/index.ts index 5db49c751..0c0073b9f 100644 --- a/src/core/server/services/comments/index.ts +++ b/src/core/server/services/comments/index.ts @@ -136,12 +136,7 @@ async function addCommentActions( export type EditComment = Omit< EditCommentInput, "status" | "author_id" | "lastEditableCommentCreatedAt" -> & { - /** - * asset_id is the asset that the comment exists on. - */ - asset_id: string; -}; +>; export async function edit( mongo: Db, @@ -150,15 +145,22 @@ export async function edit( input: EditComment, req?: Request ) { + // Get the comment that we're editing. + let comment = await retrieveComment(mongo, tenant.id, input.id); + if (!comment) { + // TODO: replace to match error returned by the models/comments.ts + throw new Error("comment not found"); + } + // Grab the asset that we'll use to check moderation pieces with. - const asset = await retrieveAsset(mongo, tenant.id, input.asset_id); + const asset = await retrieveAsset(mongo, tenant.id, comment.asset_id); if (!asset) { // TODO: (wyattjoh) return better error. throw new Error("asset referenced does not exist"); } // Run the comment through the moderation phases. - const { status } = await processForModeration({ + const { status, metadata } = await processForModeration({ asset, tenant, comment: input, @@ -168,11 +170,12 @@ export async function edit( // TODO: (wyattjoh) use the actions somehow. - const comment = await editComment(mongo, tenant.id, { + comment = await editComment(mongo, tenant.id, { id: input.id, author_id: author.id, body: input.body, status, + metadata, // The editable time is based on the current time, and the edit window // length. By subtracting the current date from the edit window length, we // get the maximum value for the `created_at` time that would be permitted diff --git a/src/locales/de/framework.ftl b/src/locales/de/framework.ftl index 55dca1c8c..7ea668be3 100644 --- a/src/locales/de/framework.ftl +++ b/src/locales/de/framework.ftl @@ -6,10 +6,10 @@ framework-validation-required = Dies ist ein Pflichtpfeld. +framework-timeago-just-now = Gerade eben framework-timeago = { $suffix -> [ago] vor - [in] in } { $value } { $unit -> diff --git a/src/locales/en-US/framework.ftl b/src/locales/en-US/framework.ftl index f03b2a50a..89eee969b 100644 --- a/src/locales/en-US/framework.ftl +++ b/src/locales/en-US/framework.ftl @@ -13,6 +13,8 @@ framework-validation-invalidEmail = Please enter a valid email address. framework-validation-passwordTooShort = Password must contain at least {$minLength} characters. framework-validation-passwordsDoNotMatch = Passwords do not match. Try again. +framework-timeago-just-now = Just now + framework-timeago-time = { $value } { $unit -> @@ -48,12 +50,7 @@ framework-timeago-time = } framework-timeago = - { $value -> - [0] now - *[other] - { $suffix -> - [ago] {framework-timeago-time} ago - [in] in {framework-timeago-time} - *[other] unknown suffix - } + { $suffix -> + [ago] {framework-timeago-time} ago + [noSuffix] {framework-timeago-time} } diff --git a/src/locales/en-US/stream.ftl b/src/locales/en-US/stream.ftl index 29aac39c9..1a2b81f5f 100644 --- a/src/locales/en-US/stream.ftl +++ b/src/locales/en-US/stream.ftl @@ -1,5 +1,17 @@ ### Localization for Embed Stream +## General + +general-userBoxUnauthenticated-joinTheConversation = Join the conversation +general-userBoxUnauthenticated-signIn = Sign in +general-userBoxUnauthenticated-register = Register + +general-userBoxAuthenticated-signedInAs = + Signed in as . + +general-userBoxAuthenticated-notYou = + Not you? + ## Comments Tab comments-streamQuery-assetNotFound = Asset not found @@ -14,16 +26,6 @@ comments-permalinkPopover-copied = Copied comments-permalinkView-showAllComments = Show all comments comments-permalinkView-commentNotFound = Comment not found -comments-userBoxUnauthenticated-joinTheConversation = Join the conversation -comments-userBoxUnauthenticated-signIn = Sign in -comments-userBoxUnauthenticated-register = Register - -comments-userBoxAuthenticated-signedInAs = - Signed in as . - -comments-userBoxAuthenticated-notYou = - Not you? - comments-rte-bold = .title = Bold @@ -53,4 +55,16 @@ comments-replyCommentForm-submit = Submit comments-replyCommentForm-cancel = Cancel comments-replyCommentForm-rteLabel = Write a reply comments-replyCommentForm-rte = - .placeholder = { comments-postCommentForm-rteLabel } + .placeholder = { comments-replyCommentForm-rteLabel } + +comments-commentContainer-editButton = Edit + +comments-editCommentForm-saveChanges = Save Changes +comments-editCommentForm-cancel = Cancel +comments-editCommentForm-close = Close +comments-editCommentForm-rteLabel = Edit comment +comments-editCommentForm-rte = + .placeholder = { comments-editCommentForm-rteLabel } +comments-editCommentForm-editRemainingTime = Edit: remaining +comments-editCommentForm-editTimeExpired = Edit time has expired. You can no longer edit this comment. Why not post another one? +comments-editedMarker-edited = Edited diff --git a/src/locales/es/framework.ftl b/src/locales/es/framework.ftl index a383c75bb..86b5e6cb4 100644 --- a/src/locales/es/framework.ftl +++ b/src/locales/es/framework.ftl @@ -2,14 +2,12 @@ ### All keys must start with `framework` because this file is shared ### among different targets. +framework-timeago-just-now = Hace poco framework-timeago = { $value -> - [0] ahora *[other] { $suffix -> [ago] hace - [in] en - *[other] unknown suffix } { $value } { $unit -> diff --git a/src/types/react-timeago.d.ts b/src/types/react-timeago.d.ts index 431bbf88e..02161745c 100644 --- a/src/types/react-timeago.d.ts +++ b/src/types/react-timeago.d.ts @@ -36,6 +36,7 @@ declare module "react-timeago" { live?: boolean; className: string; formatter?: Formatter; + minPeriod?: number; } const TimeAgo: React.ComponentType;