diff --git a/src/core/client/embed/decorators/withSetCommentID.spec.ts b/src/core/client/embed/decorators/withSetCommentID.spec.ts index ed8f17e8b..8976215c9 100644 --- a/src/core/client/embed/decorators/withSetCommentID.spec.ts +++ b/src/core/client/embed/decorators/withSetCommentID.spec.ts @@ -1,3 +1,7 @@ +import simulant from "simulant"; +import sinon from "sinon"; + +import { CleanupCallback } from "."; import withSetCommentID from "./withSetCommentID"; it("should add commentID", () => { @@ -10,8 +14,9 @@ it("should add commentID", () => { } }, }; - withSetCommentID(fakePym as any); + const cleanup = withSetCommentID(fakePym as any) as CleanupCallback; expect(location.toString()).toBe("http://localhost/?commentID=comment-id"); + cleanup(); window.history.replaceState(previousState, document.title, previousLocation); }); @@ -30,7 +35,31 @@ it("should remove commentID", () => { } }, }; - withSetCommentID(fakePym as any); + const cleanup = withSetCommentID(fakePym as any) as CleanupCallback; expect(location.toString()).toBe("http://localhost/"); + cleanup(); + window.history.replaceState(previousState, document.title, previousLocation); +}); + +it("should send commentID over pym when history changes", () => { + const previousLocation = location.toString(); + const previousState = window.history.state; + window.history.replaceState( + previousState, + document.title, + "http://localhost/?commentID=comment-id" + ); + const fakePym = { + onMessage: sinon.stub(), + sendMessage: sinon + .mock() + .once() + .withArgs("setCommentID", "comment-id"), + }; + const cleanup = withSetCommentID(fakePym as any) as CleanupCallback; + simulant.fire(window as any, "popstate"); + cleanup(); + simulant.fire(window as any, "popstate"); + fakePym.sendMessage.verify(); window.history.replaceState(previousState, document.title, previousLocation); }); diff --git a/src/core/client/embed/decorators/withSetCommentID.ts b/src/core/client/embed/decorators/withSetCommentID.ts index b2b6812a3..7e6c34be4 100644 --- a/src/core/client/embed/decorators/withSetCommentID.ts +++ b/src/core/client/embed/decorators/withSetCommentID.ts @@ -3,6 +3,10 @@ import qs from "query-string"; import { buildURL } from "../utils"; import { Decorator } from "./"; +function getCurrentCommentID() { + return qs.parse(location.search).commentID; +} + const withSetCommentID: Decorator = pym => { // Add the permalink comment id to the query. pym.onMessage("setCommentID", (id: string) => { @@ -15,8 +19,20 @@ const withSetCommentID: Decorator = pym => { const url = buildURL({ search }); // Change the url. - window.history.replaceState({}, document.title, url); + window.history.pushState({}, document.title, url); }); + + // Send new commentID when history state changes. + const sendSetCommentID = (e: Event) => { + const commentID = getCurrentCommentID(); + pym.sendMessage("setCommentID", commentID); + }; + window.addEventListener("popstate", sendSetCommentID); + + // Cleanup. + return () => { + window.removeEventListener("popstate", sendSetCommentID); + }; }; export default withSetCommentID; diff --git a/src/core/client/framework/utils/buildURL.spec.ts b/src/core/client/framework/utils/buildURL.spec.ts new file mode 100644 index 000000000..07e4e2ddc --- /dev/null +++ b/src/core/client/framework/utils/buildURL.spec.ts @@ -0,0 +1,18 @@ +import buildURL from "./buildURL"; + +it("should default to window.location", () => { + const url = buildURL(); + expect(url).toBe("http://localhost/"); +}); + +it("should build from parameters", () => { + const url = buildURL({ + protocol: "https", + hostname: "hostname", + port: "8080", + pathname: "/pathname", + search: "search", + hash: "#hash", + }); + expect(url).toBe("https//hostname:8080/pathname?search#hash"); +}); diff --git a/src/core/client/framework/utils/buildURL.ts b/src/core/client/framework/utils/buildURL.ts new file mode 100644 index 000000000..77353427f --- /dev/null +++ b/src/core/client/framework/utils/buildURL.ts @@ -0,0 +1,17 @@ +export default function buildURL({ + protocol = window.location.protocol, + hostname = window.location.hostname, + port = window.location.port, + pathname = window.location.pathname, + search = window.location.search, + hash = window.location.hash, +} = {}) { + if (search && search[0] !== "?") { + search = `?${search}`; + } else if (search === "?") { + search = ""; + } + return `${protocol}//${hostname}${ + port ? `:${port}` : "" + }${pathname}${search}${hash}`; +} diff --git a/src/core/client/framework/utils/index.ts b/src/core/client/framework/utils/index.ts new file mode 100644 index 000000000..4eca76650 --- /dev/null +++ b/src/core/client/framework/utils/index.ts @@ -0,0 +1,2 @@ +export { default as buildURL } from "./buildURL"; +export { default as parseURL } from "./parseURL"; diff --git a/src/core/client/framework/utils/parseURL.spec.ts b/src/core/client/framework/utils/parseURL.spec.ts new file mode 100644 index 000000000..6fc4b8574 --- /dev/null +++ b/src/core/client/framework/utils/parseURL.spec.ts @@ -0,0 +1,20 @@ +import parseURL from "./parseURL"; + +it("should parse url", () => { + const testCases: [[string, ReturnType]] = [ + [ + "https://coralproject.net", + { + protocol: "https:", + hostname: "coralproject.net", + port: "", + pathname: "/", + search: "", + hash: "", + }, + ], + ]; + testCases.forEach(([url, expected]) => { + expect(parseURL(url)).toEqual(expected); + }); +}); diff --git a/src/core/client/framework/utils/parseURL.ts b/src/core/client/framework/utils/parseURL.ts new file mode 100644 index 000000000..757e2ecc4 --- /dev/null +++ b/src/core/client/framework/utils/parseURL.ts @@ -0,0 +1,14 @@ +import { pick } from "lodash"; + +export default function parseURL(url: string) { + const parser = document.createElement("a"); + parser.href = url; + return pick(parser, [ + "protocol", + "hostname", + "port", + "pathname", + "search", + "hash", + ]); +} diff --git a/src/core/client/stream/components/PermalinkView.tsx b/src/core/client/stream/components/PermalinkView.tsx index b7ea70370..1bf209143 100644 --- a/src/core/client/stream/components/PermalinkView.tsx +++ b/src/core/client/stream/components/PermalinkView.tsx @@ -1,4 +1,4 @@ -import React, { StatelessComponent } from "react"; +import React, { MouseEvent, StatelessComponent } from "react"; import { Button, Flex, Typography } from "talk-ui/components"; @@ -7,25 +7,28 @@ import * as styles from "./PermalinkView.css"; export interface PermalinkViewProps { comment: {} | null; - assetURL: string | null; - onShowAllComments: () => void; + showAllCommentsHref: string | null; + onShowAllComments: (e: MouseEvent) => void; } const PermalinkView: StatelessComponent = ({ - assetURL, + showAllCommentsHref, comment, onShowAllComments, }) => { return (
- {assetURL && ( + {showAllCommentsHref && ( diff --git a/src/core/client/stream/containers/PermalinkViewContainer.tsx b/src/core/client/stream/containers/PermalinkViewContainer.tsx index 65fede9d8..096785e27 100644 --- a/src/core/client/stream/containers/PermalinkViewContainer.tsx +++ b/src/core/client/stream/containers/PermalinkViewContainer.tsx @@ -1,54 +1,67 @@ -import React from "react"; +import { Child as PymChild } from "pym.js"; +import qs from "query-string"; +import React, { MouseEvent } from "react"; import { graphql } from "react-relay"; +import { withContext } from "talk-framework/lib/bootstrap"; import { withFragmentContainer } from "talk-framework/lib/relay"; -import { PermalinkViewContainer_asset as AssetData } from "talk-stream/__generated__/PermalinkViewContainer_asset.graphql"; +import { buildURL, parseURL } from "talk-framework/utils"; import { PermalinkViewContainer_comment as CommentData } from "talk-stream/__generated__/PermalinkViewContainer_comment.graphql"; import { SetCommentIDMutation, withSetCommentIDMutation, } from "talk-stream/mutations"; + import PermalinkView from "../components/PermalinkView"; interface PermalinkViewContainerProps { comment: CommentData | null; - asset: AssetData | null; setCommentID: SetCommentIDMutation; + pym: PymChild | undefined; } class PermalinkViewContainer extends React.Component< PermalinkViewContainerProps > { - private showAllComments = () => { + private showAllComments = (e: MouseEvent) => { this.props.setCommentID({ id: null }); + e.preventDefault(); }; + private getShowAllCommentsHref() { + const { pym } = this.props; + const urlParts = parseURL((pym && pym.parentUrl) || window.location.href); + const search = qs.stringify({ + ...qs.parse(urlParts.search), + commentID: undefined, + }); + // Remove the commentId url param. + return buildURL({ ...urlParts, search }); + } public render() { - const { comment, asset } = this.props; + const { comment } = this.props; return ( ); } } -const enhanced = withSetCommentIDMutation( - withFragmentContainer<{ - comment: CommentData | null; - asset: AssetData | null; - }>({ - comment: graphql` - fragment PermalinkViewContainer_comment on Comment { - ...CommentContainer - } - `, - asset: graphql` - fragment PermalinkViewContainer_asset on Asset { - url - } - `, - })(PermalinkViewContainer) +const enhanced = withContext(ctx => ({ + pym: ctx.pym, +}))( + withSetCommentIDMutation( + withFragmentContainer<{ + comment: CommentData | null; + }>({ + comment: graphql` + fragment PermalinkViewContainer_comment on Comment { + ...CommentContainer + } + `, + })(PermalinkViewContainer) + ) ); export default enhanced; diff --git a/src/core/client/stream/index.tsx b/src/core/client/stream/index.tsx index 943d920dd..6d622216e 100644 --- a/src/core/client/stream/index.tsx +++ b/src/core/client/stream/index.tsx @@ -1,4 +1,4 @@ -import pym from "pym.js"; +import { Child as PymChild } from "pym.js"; import React from "react"; import { StatelessComponent } from "react"; import ReactDOM from "react-dom"; @@ -12,10 +12,14 @@ import { import AppContainer from "./containers/AppContainer"; import { initLocalState } from "./local"; import localesData from "./locales"; +import { withSetCommentID } from "./pym"; + +const pymFeatures = [withSetCommentID]; // This is called when the context is first initialized. -async function init({ relayEnvironment }: TalkContext) { - await initLocalState(relayEnvironment); +async function init(context: TalkContext) { + await initLocalState(context.relayEnvironment); + pymFeatures.forEach(f => f(context)); } async function main() { @@ -24,7 +28,7 @@ async function main() { init, localesData, userLocales: navigator.languages, - pym: new pym.Child({ polling: 100 }), + pym: new PymChild({ polling: 100 }), }); const Index: StatelessComponent = () => ( diff --git a/src/core/client/stream/pym/index.ts b/src/core/client/stream/pym/index.ts new file mode 100644 index 000000000..34ba3fc8f --- /dev/null +++ b/src/core/client/stream/pym/index.ts @@ -0,0 +1 @@ +export { default as withSetCommentID } from "./withSetCommentID"; diff --git a/src/core/client/stream/pym/withSetCommentID.spec.ts b/src/core/client/stream/pym/withSetCommentID.spec.ts new file mode 100644 index 000000000..3403a0092 --- /dev/null +++ b/src/core/client/stream/pym/withSetCommentID.spec.ts @@ -0,0 +1,45 @@ +import { Environment, RecordSource } from "relay-runtime"; + +import { LOCAL_ID } from "talk-framework/lib/relay"; +import { createRelayEnvironment } from "talk-framework/testHelpers"; + +import withSetCommentID from "./withSetCommentID"; + +let relayEnvironment: Environment; +const source: RecordSource = new RecordSource(); + +beforeAll(() => { + relayEnvironment = createRelayEnvironment({ + source, + }); +}); + +it("Sets comment id", () => { + const id = "comment1-id"; + const context = { + pym: { + onMessage: (eventName: string, cb: (id: string) => void) => { + expect(eventName).toBe("setCommentID"); + cb(id); + }, + }, + relayEnvironment, + }; + withSetCommentID(context as any); + expect(source.get(LOCAL_ID)!.commentID).toEqual(id); +}); + +it("Sets comment id to null when empty", () => { + const id = ""; + const context = { + pym: { + onMessage: (eventName: string, cb: (data: string) => void) => { + expect(eventName).toBe("setCommentID"); + cb(id); + }, + }, + relayEnvironment, + }; + withSetCommentID(context as any); + expect(source.get(LOCAL_ID)!.commentID).toEqual(null); +}); diff --git a/src/core/client/stream/pym/withSetCommentID.ts b/src/core/client/stream/pym/withSetCommentID.ts new file mode 100644 index 000000000..903921d9b --- /dev/null +++ b/src/core/client/stream/pym/withSetCommentID.ts @@ -0,0 +1,19 @@ +import { commitLocalUpdate } from "react-relay"; + +import { TalkContext } from "talk-framework/lib/bootstrap"; +import { LOCAL_ID } from "talk-framework/lib/relay"; + +export default function withSetCommentID({ + relayEnvironment, + pym, +}: TalkContext) { + // Sets comment id through pym. + pym!.onMessage("setCommentID", raw => { + commitLocalUpdate(relayEnvironment, s => { + const id = raw || null; + if (s.get(LOCAL_ID)!.getValue("commentID") !== id) { + s.get(LOCAL_ID)!.setValue(id, "commentID"); + } + }); + }); +} diff --git a/src/core/client/stream/queries/PermalinkViewQuery.spec.tsx b/src/core/client/stream/queries/PermalinkViewQuery.spec.tsx index 01b91218a..b29ed668e 100644 --- a/src/core/client/stream/queries/PermalinkViewQuery.spec.tsx +++ b/src/core/client/stream/queries/PermalinkViewQuery.spec.tsx @@ -6,7 +6,6 @@ import { render } from "./PermalinkViewQuery"; it("renders permalink view container", () => { const data = { props: { - asset: {}, comment: {}, } as any, error: null, diff --git a/src/core/client/stream/queries/PermalinkViewQuery.tsx b/src/core/client/stream/queries/PermalinkViewQuery.tsx index b37cdaaf5..854bd5e73 100644 --- a/src/core/client/stream/queries/PermalinkViewQuery.tsx +++ b/src/core/client/stream/queries/PermalinkViewQuery.tsx @@ -27,29 +27,23 @@ export const render = ({ return
{error.message}
; } if (props) { - return ( - - ); + return ; } return
Loading
; }; const PermalinkViewQuery: StatelessComponent = ({ - local: { commentID, assetID }, + local: { commentID }, }) => ( query={graphql` - query PermalinkViewQuery($assetID: ID!, $commentID: ID!) { - asset(id: $assetID) { - ...PermalinkViewContainer_asset - } + query PermalinkViewQuery($commentID: ID!) { comment(id: $commentID) { ...PermalinkViewContainer_comment } } `} variables={{ - assetID, commentID, }} render={render} @@ -59,7 +53,6 @@ const PermalinkViewQuery: StatelessComponent = ({ const enhanced = withLocalStateContainer( graphql` fragment PermalinkViewQueryLocal on Local { - assetID commentID } ` diff --git a/src/core/client/stream/queries/__snapshots__/PermalinkViewQuery.spec.tsx.snap b/src/core/client/stream/queries/__snapshots__/PermalinkViewQuery.spec.tsx.snap index 85a702a9a..ef1057221 100644 --- a/src/core/client/stream/queries/__snapshots__/PermalinkViewQuery.spec.tsx.snap +++ b/src/core/client/stream/queries/__snapshots__/PermalinkViewQuery.spec.tsx.snap @@ -13,8 +13,7 @@ exports[`renders loading 1`] = ` `; exports[`renders permalink view container 1`] = ` - `; diff --git a/src/core/client/stream/test/__snapshots__/permalinkView.spec.tsx.snap b/src/core/client/stream/test/__snapshots__/permalinkView.spec.tsx.snap index 8c5b5b287..359ae869f 100644 --- a/src/core/client/stream/test/__snapshots__/permalinkView.spec.tsx.snap +++ b/src/core/client/stream/test/__snapshots__/permalinkView.spec.tsx.snap @@ -7,8 +7,9 @@ exports[`renders permalink view 1`] = `
- +
diff --git a/src/core/client/stream/test/__snapshots__/permalinkViewAssetNotFound.spec.tsx.snap b/src/core/client/stream/test/__snapshots__/permalinkViewAssetNotFound.spec.tsx.snap deleted file mode 100644 index 270fae18d..000000000 --- a/src/core/client/stream/test/__snapshots__/permalinkViewAssetNotFound.spec.tsx.snap +++ /dev/null @@ -1,17 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`renders permalink view with unknown asset 1`] = ` -
-
-

- Comment not found -

-
-
-`; diff --git a/src/core/client/stream/test/__snapshots__/permalinkViewCommentNotFound.spec.tsx.snap b/src/core/client/stream/test/__snapshots__/permalinkViewCommentNotFound.spec.tsx.snap index 6e094cf1e..9f1260716 100644 --- a/src/core/client/stream/test/__snapshots__/permalinkViewCommentNotFound.spec.tsx.snap +++ b/src/core/client/stream/test/__snapshots__/permalinkViewCommentNotFound.spec.tsx.snap @@ -7,8 +7,9 @@ exports[`renders permalink view with unknown comment 1`] = `
- +

diff --git a/src/core/client/stream/test/permalinkView.spec.tsx b/src/core/client/stream/test/permalinkView.spec.tsx index 09b0c05fe..eacf3dd2d 100644 --- a/src/core/client/stream/test/permalinkView.spec.tsx +++ b/src/core/client/stream/test/permalinkView.spec.tsx @@ -75,11 +75,15 @@ it("renders permalink view", async () => { }); it("show all comments", async () => { + const mockEvent = { + preventDefault: sinon.mock().once(), + }; testRenderer.root .findByProps({ id: "talk-comments-permalinkView-showAllComments", }) - .props.onClick(); + .props.onClick(mockEvent); await timeout(); expect(testRenderer.toJSON()).toMatchSnapshot(); + mockEvent.preventDefault.verify(); }); diff --git a/src/core/client/stream/test/permalinkViewAssetNotFound.spec.tsx b/src/core/client/stream/test/permalinkViewAssetNotFound.spec.tsx deleted file mode 100644 index 4ee8ebb5c..000000000 --- a/src/core/client/stream/test/permalinkViewAssetNotFound.spec.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import React from "react"; -import TestRenderer from "react-test-renderer"; -import { RecordProxy } from "relay-runtime"; - -import { timeout } from "talk-common/utils"; -import { TalkContext, TalkContextProvider } from "talk-framework/lib/bootstrap"; -import { createRelayEnvironment } from "talk-framework/testHelpers"; -import AppContainer from "talk-stream/containers/AppContainer"; - -const resolvers = { - Query: { - comment: () => null, - asset: () => null, - }, -}; - -const environment = createRelayEnvironment({ - network: { - // Set this to true, to see graphql responses. - logNetwork: false, - resolvers, - projectName: "tenant", - }, - initLocalState: (localRecord: RecordProxy) => { - localRecord.setValue("unknown-asset-id", "assetID"); - localRecord.setValue("unknown-comment-id", "commentID"); - }, -}); - -const context: TalkContext = { - relayEnvironment: environment, - localeMessages: [], -}; - -const testRenderer = TestRenderer.create( - - - -); - -it("renders permalink view with unknown asset", async () => { - // Wait for loading. - await timeout(); - expect(testRenderer.toJSON()).toMatchSnapshot(); -}); diff --git a/src/core/client/stream/test/permalinkViewCommentNotFound.spec.tsx b/src/core/client/stream/test/permalinkViewCommentNotFound.spec.tsx index 473e6d510..ef945036b 100644 --- a/src/core/client/stream/test/permalinkViewCommentNotFound.spec.tsx +++ b/src/core/client/stream/test/permalinkViewCommentNotFound.spec.tsx @@ -71,11 +71,15 @@ it("renders permalink view with unknown comment", async () => { }); it("show all comments", async () => { + const mockEvent = { + preventDefault: sinon.mock().once(), + }; testRenderer.root .findByProps({ id: "talk-comments-permalinkView-showAllComments", }) - .props.onClick(); + .props.onClick(mockEvent); await timeout(); expect(testRenderer.toJSON()).toMatchSnapshot(); + mockEvent.preventDefault.verify(); }); diff --git a/src/core/client/ui/components/BaseButton/BaseButton.tsx b/src/core/client/ui/components/BaseButton/BaseButton.tsx index 28399c1af..a92a28d50 100644 --- a/src/core/client/ui/components/BaseButton/BaseButton.tsx +++ b/src/core/client/ui/components/BaseButton/BaseButton.tsx @@ -15,6 +15,9 @@ import * as styles from "./BaseButton.css"; interface InnerProps extends ButtonHTMLAttributes { /** If set renders an anchor tag instead */ anchor?: boolean; + href?: string; + target?: string; + /** * This prop can be used to add custom classnames. * It is handled by the `withStyles `HOC. diff --git a/src/core/client/ui/components/Button/Button.tsx b/src/core/client/ui/components/Button/Button.tsx index 72d653cfc..fe11b0dfc 100644 --- a/src/core/client/ui/components/Button/Button.tsx +++ b/src/core/client/ui/components/Button/Button.tsx @@ -11,6 +11,10 @@ import * as styles from "./Button.css"; // This should extend from BaseButton instead but we can't because of this bug // TODO: add bug link. interface InnerProps extends ButtonHTMLAttributes { + /** If set renders an anchor tag instead */ + anchor?: boolean; + href?: string; + target?: string; /** * This prop can be used to add custom classnames. * It is handled by the `withStyles `HOC.