diff --git a/common/constants.js b/common/constants.js index a14128ae4..a863e9519 100644 --- a/common/constants.js +++ b/common/constants.js @@ -9,5 +9,13 @@ module.exports = { HIGHLIGHTS_THRESHOLDS: { created: "-3 day", visited: "-30 minutes", - } + }, + + // This is how many pixels before the bottom that + // infinite scroll is triggered + INFINITE_SCROLL_THRESHOLD: 20, + + // How many pixels offset for the infinite scroll top + // due to the header? + SCROLL_TOP_OFFSET: 50 }; diff --git a/common/event-constants.js b/common/event-constants.js index db6e09fb5..c8b137130 100644 --- a/common/event-constants.js +++ b/common/event-constants.js @@ -17,6 +17,7 @@ const constants = { "UNBLOCK_ALL", "SHARE", "LOAD_MORE", + "LOAD_MORE_SCROLL", "SEARCH" ]), sources: new Set([ diff --git a/content-src/components/Loader/Loader.scss b/content-src/components/Loader/Loader.scss index 5b0519285..3ffe698c9 100644 --- a/content-src/components/Loader/Loader.scss +++ b/content-src/components/Loader/Loader.scss @@ -11,19 +11,10 @@ .spinner { flex-shrink: 0; margin-right: 10px; - width: $loader-height; - height: $loader-height; + width: $loader-size; + height: $loader-size; display: inline-block; - border-radius: 50%; - background: transparent; - border-top: $loader-border-width solid $loader-color-light; - border-right: $loader-border-width solid $loader-color-light; - border-bottom: $loader-border-width solid $loader-color; - border-left: $loader-border-width solid $loader-color; - animation: spin $loader-duration linear infinite; + background-image: url('img/loading@2x.png'); + background-size: $loader-size $loader-size; } } - -@keyframes spin { - to { transform: rotate(360deg); } -} diff --git a/content-src/components/TimelinePage/TimelineBookmarks.js b/content-src/components/TimelinePage/TimelineBookmarks.js index 2e1f437b3..1db015a94 100644 --- a/content-src/components/TimelinePage/TimelineBookmarks.js +++ b/content-src/components/TimelinePage/TimelineBookmarks.js @@ -1,43 +1,18 @@ const React = require("react"); const {connect} = require("react-redux"); const {selectBookmarks} = require("selectors/selectors"); -const GroupedActivityFeed = require("components/ActivityFeed/ActivityFeed"); -const {RequestMoreBookmarks, NotifyEvent} = require("common/action-manager").actions; -const LoadMore = require("components/LoadMore/LoadMore"); -const classNames = require("classnames"); - -const PAGE_NAME = "TIMELINE_BOOKMARKS"; +const {RequestMoreBookmarks} = require("common/action-manager").actions; +const TimelineFeed = require("./TimelineFeed"); const TimelineBookmarks = React.createClass({ - getMore() { - const bookmarks = this.props.Bookmarks.rows; - if (!bookmarks.length) { - return; - } - const beforeDate = bookmarks[bookmarks.length - 1].lastModified; - this.props.dispatch(RequestMoreBookmarks(beforeDate)); - this.props.dispatch(NotifyEvent({ - event: "LOAD_MORE", - page: "TIMELINE_BOOKMARKS", - source: "ACTIVITY_FEED" - })); - }, render() { const props = this.props; - return (
- -
); + return (); } }); diff --git a/content-src/components/TimelinePage/TimelineFeed.js b/content-src/components/TimelinePage/TimelineFeed.js new file mode 100644 index 000000000..43d265df5 --- /dev/null +++ b/content-src/components/TimelinePage/TimelineFeed.js @@ -0,0 +1,91 @@ +const React = require("react"); +const {connect} = require("react-redux"); +const {justDispatch} = require("selectors/selectors"); +const {NotifyEvent} = require("common/action-manager").actions; +const GroupedActivityFeed = require("components/ActivityFeed/ActivityFeed"); +const Spotlight = require("components/Spotlight/Spotlight"); +const Loader = require("components/Loader/Loader"); +const classNames = require("classnames"); +const {INFINITE_SCROLL_THRESHOLD, SCROLL_TOP_OFFSET} = require("common/constants"); +const debounce = require("lodash.debounce"); + +const TimelineFeed = React.createClass({ + loadMore() { + const items = this.props.Feed.rows; + if (!items.length) { + return; + } + const beforeDate = items[items.length - 1][this.props.dateKey]; + this.props.dispatch(this.props.loadMoreAction(beforeDate)); + this.props.dispatch(NotifyEvent({ + event: "LOAD_MORE_SCROLL", + page: this.props.pageName, + source: "ACTIVITY_FEED" + })); + }, + windowHeight: null, + handleScroll(values) { + const {Feed} = this.props; + const {scrollTop, scrollHeight} = values; + + if (!Feed.canLoadMore || Feed.isLoading) { + return; + } + + if (!this.windowHeight) { + this.windowHeight = window.innerHeight; + } + + if (scrollHeight - (scrollTop + this.windowHeight - SCROLL_TOP_OFFSET) < INFINITE_SCROLL_THRESHOLD) { + this.loadMore(); + } + }, + onResize: debounce(function() { + this.windowHeight = window.innerHeight; + }, 100), + onScroll: debounce(function() { + this.handleScroll({ + scrollHeight: this.refs.scrollElement.scrollHeight, + scrollTop: this.refs.scrollElement.scrollTop + }); + }, 100), + componentDidUpdate(prevProps) { + // Firefox will emit a scroll event if we don't do this + // There is a weird behaviour that makes the scroll bar stick in a lower + // position sometimes if we set scrollTop to 0 instead of 1 + if (!prevProps.Feed.init && this.props.Feed.init) { + this.refs.scrollElement.scrollTop = 1; + } + }, + componentDidMount() { + window.addEventListener("resize", this.onResize); + }, + componentWillUnmount() { + window.removeEventListener("resize", this.onResize); + }, + render() { + const props = this.props; + return (
+
+ {props.Spotlight ? : null } + + +
+
); + } +}); + +TimelineFeed.propTypes = { + Spotlight: React.PropTypes.object, + Feed: React.PropTypes.object.isRequired, + pageName: React.PropTypes.string.isRequired, + loadMoreAction: React.PropTypes.func.isRequired, + dateKey: React.PropTypes.string.isRequired +}; + +module.exports = connect(justDispatch)(TimelineFeed); +module.exports.TimelineFeed = TimelineFeed; diff --git a/content-src/components/TimelinePage/TimelineHistory.js b/content-src/components/TimelinePage/TimelineHistory.js index 67204ab26..f3496a426 100644 --- a/content-src/components/TimelinePage/TimelineHistory.js +++ b/content-src/components/TimelinePage/TimelineHistory.js @@ -1,39 +1,19 @@ const React = require("react"); const {connect} = require("react-redux"); const {selectHistory} = require("selectors/selectors"); -const {RequestMoreRecentLinks, NotifyEvent} = require("common/action-manager").actions; -const GroupedActivityFeed = require("components/ActivityFeed/ActivityFeed"); -const Spotlight = require("components/Spotlight/Spotlight"); -const LoadMore = require("components/LoadMore/LoadMore"); -const classNames = require("classnames"); - -const PAGE_NAME = "TIMELINE_ALL"; +const {RequestMoreRecentLinks} = require("common/action-manager").actions; +const TimelineFeed = require("./TimelineFeed"); const TimelineHistory = React.createClass({ - getMore() { - const history = this.props.History.rows; - if (!history.length) { - return; - } - const beforeDate = history[history.length - 1].lastVisitDate; - this.props.dispatch(RequestMoreRecentLinks(beforeDate)); - this.props.dispatch(NotifyEvent({ - event: "LOAD_MORE", - page: "TIMELINE_ALL", - source: "ACTIVITY_FEED" - })); - }, render() { const props = this.props; - return (
- - -
); + return (); } }); @@ -41,7 +21,5 @@ TimelineHistory.propTypes = { Spotlight: React.PropTypes.object.isRequired, History: React.PropTypes.object.isRequired }; - module.exports = connect(selectHistory)(TimelineHistory); - module.exports.TimelineHistory = TimelineHistory; diff --git a/content-src/components/TimelinePage/TimelinePage.js b/content-src/components/TimelinePage/TimelinePage.js index 415ec8cef..039e1b3b5 100644 --- a/content-src/components/TimelinePage/TimelinePage.js +++ b/content-src/components/TimelinePage/TimelinePage.js @@ -36,9 +36,7 @@ const TimelinePage = React.createClass({ })} -
- {this.props.children} -
+ {this.props.children} ); } diff --git a/content-src/components/TimelinePage/TimelinePage.scss b/content-src/components/TimelinePage/TimelinePage.scss index a47afdd6b..6fc061858 100644 --- a/content-src/components/TimelinePage/TimelinePage.scss +++ b/content-src/components/TimelinePage/TimelinePage.scss @@ -1,6 +1,7 @@ main.timeline { margin-top: 50px; padding: 0; + overflow: hidden; .wrapper { margin-left: 20px; @@ -66,5 +67,10 @@ main.timeline { background: $bg-grey; margin-bottom: 0; margin-left: $header-nav-width; + + .infinite-scroll { + margin-top: $base-gutter / 2; + margin-left: $feed-gutter-h; + } } } diff --git a/content-src/reducers/reducers.js b/content-src/reducers/reducers.js index 307586236..3fde178e9 100644 --- a/content-src/reducers/reducers.js +++ b/content-src/reducers/reducers.js @@ -1,6 +1,5 @@ const am = require("common/action-manager"); const setRowsOrError = require("reducers/SetRowsOrError"); -const {LINKS_QUERY_LIMIT} = require("common/constants"); function setSearchState(type) { return (prevState = {currentEngine: {}, error: false, init: false}, action) => { @@ -31,8 +30,8 @@ function setSearchState(type) { module.exports = { TopSites: setRowsOrError("TOP_FRECENT_SITES_REQUEST", "TOP_FRECENT_SITES_RESPONSE"), - History: setRowsOrError("RECENT_LINKS_REQUEST", "RECENT_LINKS_RESPONSE", LINKS_QUERY_LIMIT), - Bookmarks: setRowsOrError("RECENT_BOOKMARKS_REQUEST", "RECENT_BOOKMARKS_RESPONSE", LINKS_QUERY_LIMIT), + History: setRowsOrError("RECENT_LINKS_REQUEST", "RECENT_LINKS_RESPONSE"), + Bookmarks: setRowsOrError("RECENT_BOOKMARKS_REQUEST", "RECENT_BOOKMARKS_RESPONSE"), Highlights: setRowsOrError("HIGHLIGHTS_LINKS_REQUEST", "HIGHLIGHTS_LINKS_RESPONSE"), Search: setSearchState("SEARCH_STATE_RESPONSE") }; diff --git a/content-src/static/img/loading@2x.png b/content-src/static/img/loading@2x.png new file mode 100644 index 000000000..2e29d15a8 Binary files /dev/null and b/content-src/static/img/loading@2x.png differ diff --git a/content-src/styles/variables.scss b/content-src/styles/variables.scss index bc64ad7c1..f661fc1d4 100644 --- a/content-src/styles/variables.scss +++ b/content-src/styles/variables.scss @@ -91,12 +91,7 @@ $placeholder-border: 1px solid $faintest-black; $item-shadow: 0 1px 0 0 $faintest-black; $item-shadow-hover: 0 1px 0 0 $faintest-black, 0 0 0 5px $faintest-black; -$loader-height: 25px; -$loader-width: 25px; -$loader-border-width: 5px; -$loader-color: $search-blue; -$loader-color-light: rgba($loader-color, 0.1); -$loader-duration: 1.3s; +$loader-size: 16px; $context-menu-shadow: 0 5px 10px rgba(0, 0, 0, 0.3), 0 0 0 1px rgba(0, 0, 0, 0.2); $context-menu-font-size: 14px; diff --git a/content-test/components/TimelinePage.test.js b/content-test/components/TimelinePage.test.js index 411063690..25c112080 100644 --- a/content-test/components/TimelinePage.test.js +++ b/content-test/components/TimelinePage.test.js @@ -4,13 +4,17 @@ const ReactDOM = require("react-dom"); const TestUtils = require("react-addons-test-utils"); const TimelinePage = require("components/TimelinePage/TimelinePage"); +const ConnectedTimelineFeed = require("components/TimelinePage/TimelineFeed"); +const {TimelineFeed} = ConnectedTimelineFeed; const ConnectedTimelineHistory = require("components/TimelinePage/TimelineHistory"); const {TimelineHistory} = ConnectedTimelineHistory; const ConnectedTimelineBookmarks = require("components/TimelinePage/TimelineBookmarks"); const {TimelineBookmarks} = ConnectedTimelineBookmarks; const {GroupedActivityFeed} = require("components/ActivityFeed/ActivityFeed"); -const LoadMore = require("components/LoadMore/LoadMore"); +const Loader = require("components/Loader/Loader"); +const Spotlight = require("components/Spotlight/Spotlight"); const {mockData, renderWithProvider} = require("test/test-utils"); +const {INFINITE_SCROLL_THRESHOLD, SCROLL_TOP_OFFSET} = require("common/constants"); describe("Timeline", () => { @@ -45,15 +49,152 @@ describe("Timeline", () => { }); }); + describe("TimelineFeed", () => { + let instance; + let loader; + let loaderEl; + + const fakeProps = { + Feed: { + init: true, + isLoading: false, + canLoadMore: true, + rows: mockData.History.rows, + }, + dateKey: "lastVisitDate", + pageName: "TIMELINE_ALL", + loadMoreAction: () => {} + }; + + function setup(customProps = {}, dispatch) { + const props = Object.assign({}, fakeProps, customProps); + const connected = renderWithProvider(, dispatch && {dispatch}); + instance = TestUtils.findRenderedComponentWithType(connected, TimelineFeed); + loader = TestUtils.findRenderedComponentWithType(instance, Loader); + loaderEl = ReactDOM.findDOMNode(loader); + } + + beforeEach(setup); + + it("should create a TimelineFeed", () => { + assert.ok(TestUtils.isCompositeComponentWithType(instance, TimelineFeed)); + }); + + describe("Elements", () => { + it("should render GroupedActivityFeed with correct data", () => { + const activityFeed = TestUtils.findRenderedComponentWithType(instance, GroupedActivityFeed); + assert.equal(activityFeed.props.sites, fakeProps.Feed.rows); + assert.equal(activityFeed.props.dateKey, fakeProps.dateKey); + }); + it("should not render a Spotlight if Spotlight data is missing", () => { + assert.lengthOf(TestUtils.scryRenderedComponentsWithType(instance, Spotlight), 0); + }); + it("should render a Spotlight if Spotlight data is provided", () => { + setup({Spotlight: mockData.Highlights}); + assert.ok(TestUtils.findRenderedComponentWithType(instance, Spotlight)); + }); + }); + + describe("Loader", () => { + it("should have a Loader element", () => { + assert.ok(loader); + }); + it("should show Loader if History.isLoading is true", () => { + setup({ + Feed: Object.assign({}, fakeProps.Feed, {isLoading: true}), + }); + assert.equal(loaderEl.hidden, false); + }); + }); + + describe("#loadMore", () => { + it("should dispatch loadMoreAction", done => { + setup({loadMoreAction: () => "foo"}, action => { + assert.equal(action, "foo"); + done(); + }); + instance.loadMore(); + }); + it("should select the {dateKey} of the last item", done => { + setup({ + dateKey: "foo", + Feed: Object.assign({}, fakeProps.Feed, { + rows: [ + {url: "asd.com", foo: 3}, + {url: "blah.com", foo: 2}, + {url: "324ads.com", foo: 1} + ] + }), + loadMoreAction: date => { + assert.equal(date, 1); + done(); + } + }); + instance.loadMore(); + }); + it("should dispatch a NotifyEvent", done => { + setup({}, action => { + if (action && action.type === "NOTIFY_USER_EVENT") { + assert.equal(action.type, "NOTIFY_USER_EVENT"); + assert.equal(action.data.event, "LOAD_MORE_SCROLL"); + assert.equal(action.data.page, fakeProps.pageName); + done(); + } + }); + instance.loadMore(); + }); + }); + + describe("#handleScroll", function() { + it("should set this.windowHeight if it is falsey", () => { + instance.windowHeight = null; + instance.handleScroll({scrollTop: 0, scrollHeight: 5000}); + assert.equal(instance.windowHeight, window.innerHeight); + }); + it("should not call loadMore if the scrollTop is before the threshold", () => { + setup({loadMoreAction: () => { + throw new Error("Should not call loadMore"); + }}); + instance.windowHeight = 200; + instance.handleScroll({scrollTop: 0, scrollHeight: 400}); + }); + it("should loadMore if the scrollTop is past the threshold", done => { + setup({loadMoreAction: () => done()}); + instance.windowHeight = 200; + const scrollTop = 200 + SCROLL_TOP_OFFSET - INFINITE_SCROLL_THRESHOLD + 1; + instance.handleScroll({scrollTop, scrollHeight: 200}); + }); + it("should not call loadMore if canLoadMore is false", () => { + setup({ + Feed: Object.assign({}, fakeProps.Feed, {canLoadMore: false}), + loadMoreAction: () => { + throw new Error("Should not call loadMore"); + } + }); + instance.windowHeight = 200; + instance.handleScroll({scrollTop: 1000, scrollHeight: 200}); + }); + it("should not call loadMore if isLoading is true", () => { + setup({ + Feed: Object.assign({}, fakeProps.Feed, {isLoading: true}), + loadMoreAction: () => { + throw new Error("Should not call loadMore"); + } + }); + instance.windowHeight = 200; + instance.handleScroll({scrollTop: 1000, scrollHeight: 200}); + }); + }); + + }); + describe("TimelineHistory", () => { let instance; - let loadMore; const fakeProps = mockData; function setup(customProps = {}, dispatch) { const props = Object.assign({}, fakeProps, customProps); instance = renderWithProvider(, dispatch && {dispatch}); - loadMore = TestUtils.findRenderedComponentWithType(instance, LoadMore); } beforeEach(setup); @@ -62,64 +203,20 @@ describe("Timeline", () => { assert.ok(TestUtils.isCompositeComponentWithType(instance, TimelineHistory)); }); - it("should render GroupedActivityFeed with correct data", () => { - const activityFeed = TestUtils.findRenderedComponentWithType(instance, GroupedActivityFeed); - assert.equal(activityFeed.props.sites, fakeProps.History.rows); - }); - it("should render the connected container with the correct props", () => { const container = renderWithProvider(); const inner = TestUtils.findRenderedComponentWithType(container, TimelineHistory); Object.keys(TimelineHistory.propTypes).forEach(key => assert.property(inner.props, key)); }); - - it("should have a LoadMore element", () => { - assert.ok(loadMore); - }); - - it("should show a loader if History.isLoading is true", () => { - setup({ - History: { - isLoading: true, - canLoadMore: true, - rows: [{url: "https://foo.com"}] - } - }); - assert.equal(ReactDOM.findDOMNode(loadMore.refs.loader).hidden, false); - }); - - it("should hide LoadMore if canLoadMore is false", () => { - setup({ - History: { - isLoading: false, - canLoadMore: false, - rows: [{url: "https://foo.com"}] - } - }); - assert.equal(ReactDOM.findDOMNode(loadMore).hidden, true); - }); - - it("should hide LoadMore if rows are empty", () => { - setup({ - History: { - isLoading: false, - canLoadMore: true, - rows: [] - } - }); - assert.equal(ReactDOM.findDOMNode(loadMore).hidden, true); - }); }); describe("TimelineBookmarks", () => { let instance; - let loadMore; const fakeProps = mockData; function setup(customProps = {}, dispatch) { const props = Object.assign({}, fakeProps, customProps); instance = renderWithProvider(, dispatch && {dispatch}); - loadMore = TestUtils.findRenderedComponentWithType(instance, LoadMore); } beforeEach(setup); @@ -128,53 +225,11 @@ describe("Timeline", () => { assert.ok(TestUtils.isCompositeComponentWithType(instance, TimelineBookmarks)); }); - it("should render GroupedActivityFeed with correct data", () => { - const activityFeed = TestUtils.findRenderedComponentWithType(instance, GroupedActivityFeed); - assert.equal(activityFeed.props.sites, fakeProps.Bookmarks.rows); - }); - it("should render the connected container with the correct props", () => { const container = renderWithProvider(); const inner = TestUtils.findRenderedComponentWithType(container, TimelineBookmarks); Object.keys(TimelineBookmarks.propTypes).forEach(key => assert.property(inner.props, key)); }); - - it("should have a LoadMore element", () => { - assert.ok(loadMore); - }); - - it("should show a loader if Bookmarks.isLoading is true", () => { - setup({ - Bookmarks: { - isLoading: true, - canLoadMore: true, - rows: [{url: "https://foo.com"}] - } - }); - assert.equal(ReactDOM.findDOMNode(loadMore.refs.loader).hidden, false); - }); - - it("should hide LoadMore if canLoadMore is false", () => { - setup({ - Bookmarks: { - isLoading: false, - canLoadMore: false, - rows: [{url: "https://foo.com"}] - } - }); - assert.equal(ReactDOM.findDOMNode(loadMore).hidden, true); - }); - - it("should hide LoadMore if rows are empty", () => { - setup({ - Bookmarks: { - isLoading: false, - canLoadMore: true, - rows: [] - } - }); - assert.equal(ReactDOM.findDOMNode(loadMore).hidden, true); - }); }); }); diff --git a/content-test/test-utils.js b/content-test/test-utils.js index ae41b8cf1..7dec298eb 100644 --- a/content-test/test-utils.js +++ b/content-test/test-utils.js @@ -1,4 +1,5 @@ const React = require("react"); +const ReactDOM = require("react-dom"); const {Provider} = require("react-redux"); const mockData = require("lib/fake-data"); const {selectNewTabSites} = require("selectors/selectors"); @@ -10,7 +11,7 @@ const DEFAULT_STORE = { subscribe: () => {} }; -function createMockProvider(custom = {}) { +function createMockProvider(custom) { const store = Object.assign({}, DEFAULT_STORE, custom); store.subscribe = () => {}; return React.createClass({ @@ -20,9 +21,10 @@ function createMockProvider(custom = {}) { }); } -function renderWithProvider(component, store) { - const ProviderWrapper = createMockProvider(store); - const container = TestUtils.renderIntoDocument({component}); +function renderWithProvider(component, store, node) { + const ProviderWrapper = createMockProvider(store && store); + const render = node ? instance => ReactDOM.render(instance, node) : TestUtils.renderIntoDocument; + const container = render({component}); return TestUtils.findRenderedComponentWithType(container, component.type); } diff --git a/package.json b/package.json index 7e2742b21..0f2d5d9c2 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "classnames": "2.2.3", "fancy-dedupe": "0.1.0", "history": "1.17.0", + "lodash.debounce": "4.0.6", "moment": "2.11.2", "react": "0.14.8", "react-dom": "0.14.8",