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",