diff --git a/ansible/group_vars/staging.yml b/ansible/group_vars/staging.yml
index 560c155b0a..d4703f0bfd 100644
--- a/ansible/group_vars/staging.yml
+++ b/ansible/group_vars/staging.yml
@@ -1,3 +1,3 @@
---
-git_branch: mcp-server
+git_branch: main
disable_consul_services_ie_staging: yes
diff --git a/apps/reader/models.py b/apps/reader/models.py
index ec2dcb3c1e..2f52a74d5b 100644
--- a/apps/reader/models.py
+++ b/apps/reader/models.py
@@ -748,6 +748,68 @@ def push_next_from_feed(feed_id):
return story_hashes, unread_feed_story_hashes
+ @classmethod
+ def peek_new_story_hashes(
+ cls,
+ user_id,
+ feed_ids,
+ read_filter="all",
+ known_story_hashes=None,
+ limit=25,
+ ):
+ """Read the top `limit` story hashes for a feed or river WITHOUT touching
+ the ranked-stories paging cache, then return any hashes not already in
+ `known_story_hashes`.
+
+ Used by the new-stories indicator (see media/js/newsblur/views/new_stories_indicator_view.js)
+ to let the client poll for stories that arrived while the user is reading, without
+ disturbing the paging cache they've already built up via infinite scroll.
+
+ Only supports order='newest' in v1; oldest-first is not meaningful here because
+ new arrivals would land at the end, not the top.
+
+ Args:
+ user_id: User ID.
+ feed_ids: List of feed IDs. Single-element list = single feed; multiple = river.
+ read_filter: 'all', 'unread', or 'starred'. 'starred' returns [] (no peek semantics).
+ known_story_hashes: Set/list of story hashes the client already has loaded.
+ limit: Max hashes to inspect (default 25).
+
+ Returns:
+ List of story hashes present on the server but missing from the client's set.
+ Empty list if no peek is possible (e.g. no cache exists for this river yet).
+ """
+ if not feed_ids:
+ return []
+ if read_filter == "starred":
+ return []
+ known = set(known_story_hashes or [])
+
+ rt = redis.Redis(connection_pool=settings.REDIS_STORY_HASH_POOL)
+
+ if len(feed_ids) == 1:
+ feed_id = feed_ids[0]
+ if read_filter == "unread":
+ key = f"zU:{user_id}:{feed_id}"
+ if not rt.exists(key):
+ return []
+ top_hashes = rt.zrevrange(key, 0, limit - 1)
+ else:
+ key = f"zF:{feed_id}"
+ if not rt.exists(key):
+ return []
+ top_hashes = rt.zrevrange(key, 0, limit - 1)
+ else:
+ ranked_stories_key, unread_ranked_stories_key = cls.get_river_cache_keys(user_id, feed_ids)
+ key = unread_ranked_stories_key if read_filter == "unread" else ranked_stories_key
+ if not rt.exists(key):
+ return []
+ top_hashes = rt.zrevrange(key, 0, limit - 1)
+
+ # Redis may return bytes; normalize to str.
+ top_hashes = [h.decode("utf-8") if isinstance(h, bytes) else h for h in top_hashes]
+ return [h for h in top_hashes if h not in known]
+
def oldest_manual_unread_story_date(self, r=None):
if not r:
r = redis.Redis(connection_pool=settings.REDIS_STORY_HASH_POOL)
diff --git a/apps/reader/tests.py b/apps/reader/tests.py
index 0c6ca06208..34d44c299a 100644
--- a/apps/reader/tests.py
+++ b/apps/reader/tests.py
@@ -554,3 +554,109 @@ def test_finish_fetch_publishes_done_before_sync(
# Verify fetch_archive:done was published
mock_r.publish.assert_called_with(self.user.username, "fetch_archive:done")
+
+
+class Test_PeekNewStoryHashes(TestCase):
+ """Tests for UserSubscription.peek_new_story_hashes — the new-stories
+ indicator's ranked-stories peek. Must return the diff between server-side
+ top hashes and the client's known hashes, without touching the paging
+ cache keys that infinite scroll relies on."""
+
+ def setUp(self):
+ self.user = User.objects.create_user(username="peekuser", password="testpass", email="peek@test.com")
+
+ @patch("apps.reader.models.redis.Redis")
+ def test_returns_empty_when_no_feed_ids(self, mock_redis_cls):
+ result = UserSubscription.peek_new_story_hashes(
+ self.user.pk, feed_ids=[], known_story_hashes=[]
+ )
+ self.assertEqual(result, [])
+ mock_redis_cls.assert_not_called()
+
+ @patch("apps.reader.models.redis.Redis")
+ def test_returns_empty_for_starred_filter(self, mock_redis_cls):
+ result = UserSubscription.peek_new_story_hashes(
+ self.user.pk, feed_ids=[42], read_filter="starred", known_story_hashes=[]
+ )
+ self.assertEqual(result, [])
+
+ @patch("apps.reader.models.redis.Redis")
+ def test_single_feed_peek_reads_zF_key(self, mock_redis_cls):
+ mock_r = MagicMock()
+ mock_redis_cls.return_value = mock_r
+ mock_r.exists.return_value = True
+ mock_r.zrevrange.return_value = [b"42:aaa", b"42:bbb", b"42:ccc"]
+
+ result = UserSubscription.peek_new_story_hashes(
+ self.user.pk, feed_ids=[42], read_filter="all", known_story_hashes=["42:bbb"]
+ )
+
+ # Only unseen hashes, in server order, normalized to str
+ self.assertEqual(result, ["42:aaa", "42:ccc"])
+ mock_r.exists.assert_called_once_with("zF:42")
+ mock_r.zrevrange.assert_called_once_with("zF:42", 0, 24)
+ # Critical: peek must NOT delete, zadd, or expire the paging cache
+ mock_r.delete.assert_not_called()
+ mock_r.zadd.assert_not_called()
+ mock_r.expire.assert_not_called()
+
+ @patch("apps.reader.models.redis.Redis")
+ def test_single_feed_unread_reads_zU_key(self, mock_redis_cls):
+ mock_r = MagicMock()
+ mock_redis_cls.return_value = mock_r
+ mock_r.exists.return_value = True
+ mock_r.zrevrange.return_value = [b"42:aaa"]
+
+ result = UserSubscription.peek_new_story_hashes(
+ self.user.pk, feed_ids=[42], read_filter="unread", known_story_hashes=[]
+ )
+
+ self.assertEqual(result, ["42:aaa"])
+ mock_r.exists.assert_called_once_with("zU:%s:42" % self.user.pk)
+ mock_r.delete.assert_not_called()
+
+ @patch("apps.reader.models.redis.Redis")
+ def test_river_peek_uses_shared_cache_key(self, mock_redis_cls):
+ mock_r = MagicMock()
+ mock_redis_cls.return_value = mock_r
+ mock_r.exists.return_value = True
+ mock_r.zrevrange.return_value = [b"7:abc", b"11:def"]
+
+ result = UserSubscription.peek_new_story_hashes(
+ self.user.pk, feed_ids=[7, 11], read_filter="unread", known_story_hashes=["11:def"]
+ )
+
+ self.assertEqual(result, ["7:abc"])
+ # River peek targets the user's merged ranked-stories key, not zF:
+ called_key = mock_r.exists.call_args[0][0]
+ self.assertTrue(called_key.startswith("zhU:") or called_key.startswith("zU:"))
+ # And does not disturb that cache
+ mock_r.delete.assert_not_called()
+ mock_r.zadd.assert_not_called()
+
+ @patch("apps.reader.models.redis.Redis")
+ def test_returns_empty_when_cache_missing(self, mock_redis_cls):
+ mock_r = MagicMock()
+ mock_redis_cls.return_value = mock_r
+ mock_r.exists.return_value = False
+
+ result = UserSubscription.peek_new_story_hashes(
+ self.user.pk, feed_ids=[42], read_filter="all", known_story_hashes=[]
+ )
+
+ self.assertEqual(result, [])
+ mock_r.zrevrange.assert_not_called()
+ mock_r.delete.assert_not_called()
+
+ @patch("apps.reader.models.redis.Redis")
+ def test_drops_known_hashes_preserving_order(self, mock_redis_cls):
+ mock_r = MagicMock()
+ mock_redis_cls.return_value = mock_r
+ mock_r.exists.return_value = True
+ mock_r.zrevrange.return_value = [b"42:a", b"42:b", b"42:c", b"42:d"]
+
+ result = UserSubscription.peek_new_story_hashes(
+ self.user.pk, feed_ids=[42], read_filter="all", known_story_hashes=["42:b", "42:c"]
+ )
+
+ self.assertEqual(result, ["42:a", "42:d"])
diff --git a/apps/reader/views.py b/apps/reader/views.py
index 0205effba0..e67321fe73 100644
--- a/apps/reader/views.py
+++ b/apps/reader/views.py
@@ -859,6 +859,40 @@ def refresh_feeds(request):
social_feed_ids = [feed_id for feed_id in feed_ids if "social:" in feed_id]
feed_ids = list(set(feed_ids) - set(social_feed_ids))
+ # apps/reader/views.py: New-stories peek — let the client ask "are there any
+ # story hashes I don't already have in the currently-open feed/folder?"
+ # The client passes known_story_hashes[] + a scope; we read the ranked-stories
+ # Redis cache without mutating it (see UserSubscription.peek_new_story_hashes).
+ check_new_stories = get_post.get("check_new_stories")
+ new_story_hashes = None
+ if check_new_stories and get_post.get("check_new_stories_order", "newest") == "newest":
+ known_hashes = get_post.getlist("known_story_hashes") or get_post.getlist("known_story_hashes[]")
+ read_filter = get_post.get("check_new_stories_read_filter", "all")
+ peek_feed_ids = []
+ if check_new_stories == "feed":
+ feed_id_raw = get_post.get("check_new_stories_feed_id")
+ if feed_id_raw:
+ try:
+ peek_feed_ids = [int(feed_id_raw)]
+ except (TypeError, ValueError):
+ peek_feed_ids = []
+ elif check_new_stories == "river":
+ raw_ids = get_post.getlist("check_new_stories_feed_ids") or get_post.getlist(
+ "check_new_stories_feed_ids[]"
+ )
+ for raw in raw_ids:
+ try:
+ peek_feed_ids.append(int(raw))
+ except (TypeError, ValueError):
+ continue
+ if peek_feed_ids:
+ new_story_hashes = UserSubscription.peek_new_story_hashes(
+ user.pk,
+ feed_ids=peek_feed_ids,
+ read_filter=read_filter,
+ known_story_hashes=known_hashes,
+ )
+
feeds = {}
if feed_ids or (not social_feed_ids and not feed_ids):
feeds = UserSubscription.feeds_with_updated_counts(
@@ -921,11 +955,14 @@ def refresh_feeds(request):
MAnalyticsLoader.add(page_load=time.time() - start_time)
- return {
+ response = {
"feeds": feeds,
"social_feeds": social_feeds,
"interactions_count": interactions_count,
}
+ if new_story_hashes is not None:
+ response["new_story_hashes"] = new_story_hashes
+ return response
@json.json_view
diff --git a/media/css/reader/darkmode.css b/media/css/reader/darkmode.css
index 6db716fc16..b8e50147db 100644
--- a/media/css/reader/darkmode.css
+++ b/media/css/reader/darkmode.css
@@ -2274,6 +2274,28 @@ border-bottom-color: #303739;
color: #808080;
}
+/* darkmode.css: Dark variant of the floating new-stories pill —
+ elevated surface tone + stronger shadow so it reads on dark bg. */
+.NB-dark .NB-new-stories-indicator {
+ background: #2a2c30;
+ color: #e0e0e0;
+ border-color: rgba(255, 255, 255, 0.06);
+ box-shadow:
+ 0 1px 2px rgba(0, 0, 0, 0.30),
+ 0 6px 16px rgba(0, 0, 0, 0.40);
+}
+
+.NB-dark .NB-new-stories-indicator:hover {
+ background: #32353a;
+ box-shadow:
+ 0 1px 2px rgba(0, 0, 0, 0.32),
+ 0 8px 20px rgba(0, 0, 0, 0.44);
+}
+
+.NB-dark .NB-new-stories-indicator-icon {
+ color: #7fa9e0;
+}
+
.NB-dark .NB-feed-fetching-spinner {
border-color: #404040;
border-top-color: #5C89C9;
diff --git a/media/css/reader/reader.css b/media/css/reader/reader.css
index f46bbe8720..d61b90ad0c 100644
--- a/media/css/reader/reader.css
+++ b/media/css/reader/reader.css
@@ -4183,6 +4183,94 @@ img.feed_favicon {
}
}
+/* reader.css: Floating "N new stories" pill shown while the user is reading
+ a feed or folder and new stories arrive on the poll. Fixed to viewport so
+ it stays visible regardless of scroll; appended into #story_titles, but
+ position: fixed takes it out of the scroll container's flow so nothing
+ below it shifts when it appears. Paired with
+ media/js/newsblur/views/new_stories_indicator_view.js. */
+.NB-new-stories-indicator {
+ position: fixed;
+ top: 12px;
+ left: 50%;
+ z-index: 30;
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ padding: 7px 14px 7px 12px;
+ background: #ffffff;
+ color: #2b2b2b;
+ font-size: 12px;
+ font-weight: 500;
+ line-height: 1;
+ letter-spacing: 0.01em;
+ border-radius: 999px;
+ border: 1px solid rgba(15, 17, 20, 0.08);
+ box-shadow:
+ 0 1px 2px rgba(15, 17, 20, 0.06),
+ 0 6px 16px rgba(15, 17, 20, 0.10);
+ cursor: pointer;
+ user-select: none;
+ opacity: 0;
+ transform: translate(-50%, -6px);
+ pointer-events: none;
+ transition:
+ opacity 180ms ease-out,
+ transform 220ms cubic-bezier(0.22, 1, 0.36, 1),
+ box-shadow 180ms ease-out,
+ background-color 120ms ease-out;
+}
+
+.NB-new-stories-indicator.NB-visible {
+ opacity: 1;
+ transform: translate(-50%, 0);
+ pointer-events: auto;
+}
+
+.NB-new-stories-indicator:hover {
+ background: #fafafa;
+ box-shadow:
+ 0 1px 2px rgba(15, 17, 20, 0.08),
+ 0 8px 20px rgba(15, 17, 20, 0.12);
+ transform: translate(-50%, -1px);
+}
+
+.NB-new-stories-indicator:active {
+ transform: translate(-50%, 0);
+ box-shadow:
+ 0 1px 2px rgba(15, 17, 20, 0.10),
+ 0 4px 10px rgba(15, 17, 20, 0.10);
+}
+
+.NB-new-stories-indicator:focus-visible {
+ outline: 2px solid #5C89C9;
+ outline-offset: 2px;
+}
+
+.NB-new-stories-indicator.NB-loading {
+ pointer-events: none;
+ opacity: 0.7;
+}
+
+.NB-new-stories-indicator-icon {
+ display: inline-flex;
+ color: #5C89C9;
+}
+
+.NB-new-stories-indicator-icon svg {
+ display: block;
+}
+
+.NB-new-stories-indicator-count {
+ font-variant-numeric: tabular-nums;
+ font-weight: 600;
+ margin-right: 2px;
+}
+
+.NB-new-stories-indicator-text {
+ font-weight: 500;
+}
+
.NB-feed-fetching-indicator {
display: flex;
flex-direction: column;
diff --git a/media/js/newsblur/common/assetmodel.js b/media/js/newsblur/common/assetmodel.js
index f62dca84b3..8b95324df7 100644
--- a/media/js/newsblur/common/assetmodel.js
+++ b/media/js/newsblur/common/assetmodel.js
@@ -1244,6 +1244,16 @@ NEWSBLUR.AssetModel = Backbone.Router.extend({
data['feed_id'] = feed_id;
}
+ // assetmodel.js: Piggyback the new-stories peek on the 1-minute feed
+ // refresh so we don't need a second timer. Reader fills in scope +
+ // known hashes if a feed/folder is open; see reader.js
+ // build_new_stories_check_params.
+ var new_stories_params = (NEWSBLUR.reader && _.isFunction(NEWSBLUR.reader.build_new_stories_check_params)) ?
+ NEWSBLUR.reader.build_new_stories_check_params() : null;
+ if (new_stories_params) {
+ _.extend(data, new_stories_params);
+ }
+
if (NEWSBLUR.Globals.is_authenticated || feed_id) {
this.make_request('/reader/refresh_feeds', data, pre_callback, error_callback);
}
diff --git a/media/js/newsblur/models/stories.js b/media/js/newsblur/models/stories.js
index 5e4829c2aa..a0ee5fb065 100644
--- a/media/js/newsblur/models/stories.js
+++ b/media/js/newsblur/models/stories.js
@@ -439,6 +439,25 @@ NEWSBLUR.Collections.Stories = Backbone.Collection.extend({
}
},
+ // stories.js: Insert fresh stories at the top of the collection without
+ // bumping page_fill_outs or no_more_stories. Paired with the
+ // new-stories indicator (views/new_stories_indicator_view.js): the
+ // indicator peeks at the server for hashes the user doesn't have yet,
+ // then hands them here when the user clicks "N new stories". Matches the
+ // assetmodel.js load_feed_precallback pattern of silent-add + manual
+ // trigger so story_titles_view.js can read options.added / options.prepend.
+ prepend_new_stories: function (stories) {
+ if (!stories || !stories.length) return [];
+ var fresh = _.filter(stories, _.bind(function (story) {
+ var hash = story && (story.story_hash || (story.get && story.get('story_hash')));
+ return hash && !this.get_by_story_hash(hash);
+ }, this));
+ if (!fresh.length) return [];
+ this.add(fresh, { at: 0, silent: true });
+ this.trigger('add', { added: fresh.length, prepend: true });
+ return fresh;
+ },
+
mark_read_pubsub: function (story_hash) {
var story = this.get_by_story_hash(story_hash);
if (!story) return;
diff --git a/media/js/newsblur/reader/reader.js b/media/js/newsblur/reader/reader.js
index 0a01dc4f45..0a491feb35 100644
--- a/media/js/newsblur/reader/reader.js
+++ b/media/js/newsblur/reader/reader.js
@@ -147,6 +147,9 @@
NEWSBLUR.app.story_titles_header = new NEWSBLUR.Views.StoryTitlesHeader();
NEWSBLUR.app.search_header = new NEWSBLUR.Views.FeedSearchHeader();
NEWSBLUR.app.media_player = new NEWSBLUR.Views.MediaPlayerView();
+ NEWSBLUR.app.new_stories_indicator = new NEWSBLUR.Views.NewStoriesIndicatorView({
+ $container: this.$s.$story_titles
+ }).render();
NEWSBLUR.assets.feeds.bind('reset', _.bind(function () {
this.load_dashboard_rivers();
@@ -1413,6 +1416,14 @@
// Dismiss any pending mark-read confirmation
this.dismiss_mark_read_confirm();
+ // reader.js: Drop any pending new-stories indicator state when
+ // the user switches views. Stale pills from a different feed
+ // would be confusing.
+ this.new_story_hashes = [];
+ if (NEWSBLUR.app.new_stories_indicator) {
+ NEWSBLUR.app.new_stories_indicator.hide();
+ }
+
// Flush read time for the current story before resetting
if (NEWSBLUR.ReadTimeTracker && this.active_story) {
var story_hash = this.active_story.get('story_hash');
@@ -7397,12 +7408,154 @@
NEWSBLUR.app.sidebar_header.update_interactions_count(data.interactions_count);
+ if (data && _.isArray(data.new_story_hashes)) {
+ this.handle_new_story_hashes(data.new_story_hashes);
+ }
+
this.flags['refresh_inline_feed_delay'] = false;
this.flags['pause_feed_refreshing'] = false;
this.check_feed_fetch_progress();
this.toggle_focus_in_slider();
},
+ // reader.js: Build the payload that tells refresh_feeds what scope to
+ // peek at for new-arrival story hashes. Returns null when the user
+ // isn't in a supported view (e.g. starred, briefing, splash,
+ // oldest-first, or a search). Called from assetmodel.refresh_feeds.
+ build_new_stories_check_params: function () {
+ if (!this.active_feed) return null;
+ if (this.flags['splash_page_frontmost']) return null;
+ if (this.flags['starred_view']) return null;
+ if (this.flags['briefing_view']) return null;
+ if (this.flags['trending_view']) return null;
+ if (this.flags['social_view']) return null;
+ if (this.flags['search']) return null;
+ if (this.model.view_setting(this.active_feed, 'order') !== 'newest') return null;
+
+ var known_hashes = this.model.stories.pluck('story_hash');
+ var read_filter = this.model.view_setting(this.active_feed, 'read_filter') || 'all';
+
+ if (this.flags['river_view']) {
+ if (!this.active_folder || typeof this.active_folder.feed_ids_in_folder !== 'function') {
+ return null;
+ }
+ var feed_ids = this.active_folder.feed_ids_in_folder();
+ if (!feed_ids || !feed_ids.length) return null;
+ return {
+ 'check_new_stories': 'river',
+ 'check_new_stories_feed_ids': feed_ids,
+ 'check_new_stories_order': 'newest',
+ 'check_new_stories_read_filter': read_filter,
+ 'known_story_hashes': known_hashes
+ };
+ }
+
+ // Single feed. `active_feed` is numeric for regular feeds; bail
+ // out for any string-prefixed pseudo-feed (starred:, social:, etc).
+ if (!_.isNumber(this.active_feed) && isNaN(parseInt(this.active_feed, 10))) return null;
+ return {
+ 'check_new_stories': 'feed',
+ 'check_new_stories_feed_id': parseInt(this.active_feed, 10),
+ 'check_new_stories_order': 'newest',
+ 'check_new_stories_read_filter': read_filter,
+ 'known_story_hashes': known_hashes
+ };
+ },
+
+ // reader.js: Stash new-arrival hashes reported by the poll, surface
+ // the "N new stories" pill. Extra guard against race conditions:
+ // filter out hashes already present in the collection.
+ handle_new_story_hashes: function (hashes) {
+ if (!NEWSBLUR.app.new_stories_indicator) return;
+ if (!hashes || !hashes.length) {
+ this.new_story_hashes = [];
+ NEWSBLUR.app.new_stories_indicator.hide();
+ return;
+ }
+ var stories = this.model.stories;
+ var fresh = _.filter(hashes, function (hash) {
+ return !stories.get_by_story_hash(hash);
+ });
+ this.new_story_hashes = fresh;
+ if (!fresh.length) {
+ NEWSBLUR.app.new_stories_indicator.hide();
+ return;
+ }
+ NEWSBLUR.app.new_stories_indicator.show(fresh.length);
+ },
+
+ // reader.js: Click handler on the pill. Scroll the story list to
+ // the top, fetch full story bodies for the new hashes, prepend them
+ // via NEWSBLUR.Collections.Stories.prepend_new_stories so pagination
+ // is undisturbed, then hide the pill. Uses a direct request rather
+ // than load_feed/fetch_river_stories because those APPEND via
+ // load_feed_precallback — we need to prepend.
+ load_new_stories_from_indicator: function () {
+ var indicator = NEWSBLUR.app.new_stories_indicator;
+ if (!indicator) return;
+ var hashes = this.new_story_hashes;
+ if (!hashes || !hashes.length) {
+ indicator.hide();
+ return;
+ }
+ indicator.set_loading(true);
+
+ var self = this;
+ var feed_id_at_request = this.active_feed;
+ var finish = function () {
+ self.new_story_hashes = [];
+ indicator.hide();
+ };
+
+ var $scroll = this.$s.$story_titles;
+ if ($scroll && $scroll.length && $.isFunction($scroll.scrollTo)) {
+ $scroll.scrollTo(0, { duration: 250, easing: 'easeOutQuart' });
+ } else if ($scroll && $scroll.length) {
+ $scroll.scrollTop(0);
+ }
+
+ var handle_success = function (data) {
+ // reader.js: If the user switched feeds mid-request, discard.
+ if (self.active_feed !== feed_id_at_request) {
+ finish();
+ return;
+ }
+ if (data && data.stories && data.stories.length) {
+ self.model.stories.prepend_new_stories(data.stories);
+ }
+ finish();
+ };
+
+ // reader.js: Always use /reader/river_stories for h[] fetches —
+ // it natively supports rehydrating specific hashes (load_river_stories__redis
+ // has a story_hashes short-circuit). Single feeds just send a
+ // one-element feeds list. Paging / premium limits don't apply
+ // because the server returns MStory.objects(story_hash__in=...)
+ // unsliced when h[] is present.
+ var feeds;
+ if (this.flags['river_view']) {
+ if (!this.active_folder || typeof this.active_folder.feed_ids_in_folder !== 'function') {
+ finish();
+ return;
+ }
+ feeds = this.active_folder.feed_ids_in_folder();
+ } else {
+ feeds = [parseInt(this.active_feed, 10)];
+ }
+ var params = {
+ feeds: feeds,
+ page: 1,
+ order: this.model.view_setting(this.active_feed, 'order'),
+ read_filter: 'all',
+ include_hidden: true,
+ h: hashes
+ };
+ this.model.make_request('/reader/river_stories', params, handle_success, finish, {
+ 'ajax_group': 'feed_page',
+ 'request_type': 'GET'
+ });
+ },
+
feed_unread_count: function (feed_id, options) {
options = options || {};
feed_id = feed_id || this.active_feed;
diff --git a/media/js/newsblur/views/new_stories_indicator_view.js b/media/js/newsblur/views/new_stories_indicator_view.js
new file mode 100644
index 0000000000..d264e4d266
--- /dev/null
+++ b/media/js/newsblur/views/new_stories_indicator_view.js
@@ -0,0 +1,92 @@
+// new_stories_indicator_view.js: Floating pill over the story titles pane
+// that tells the user "N new stories" when our refresh_feeds poll reports
+// story hashes they don't have yet. Click prepends those stories at the
+// top of the collection and smooth-scrolls there. See reader.js
+// handle_new_story_hashes / load_new_stories_from_indicator for the wiring.
+NEWSBLUR.Views.NewStoriesIndicatorView = Backbone.View.extend({
+
+ className: 'NB-new-stories-indicator',
+
+ events: {
+ 'click': 'on_click'
+ },
+
+ initialize: function (options) {
+ options = options || {};
+ this.count = 0;
+ this.visible = false;
+ this.loading = false;
+ this.$container = options.$container;
+ },
+
+ render: function () {
+ this.$el.attr('role', 'button');
+ this.$el.attr('tabindex', '0');
+ this.$el.html(this._markup());
+ if (this.$container && this.$container.length) {
+ this.$container.append(this.$el);
+ }
+ return this;
+ },
+
+ _markup: function () {
+ // new_stories_indicator_view.js: Arrow icon + count + label.
+ // Uses inline SVG so we don't ship a separate asset for one icon.
+ return [
+ '',
+ ' ',
+ '',
+ '',
+ ' 0',
+ ' new stories',
+ ''
+ ].join('');
+ },
+
+ show: function (count) {
+ if (!count || count < 1) {
+ this.hide();
+ return;
+ }
+ if (this.loading) return;
+ this.count = count;
+ this._update_text();
+ if (!this.visible) {
+ this.visible = true;
+ // Next tick so the CSS transition runs instead of snapping in.
+ _.defer(_.bind(function () {
+ this.$el.addClass('NB-visible');
+ }, this));
+ }
+ },
+
+ hide: function () {
+ if (!this.visible && !this.loading) return;
+ this.visible = false;
+ this.loading = false;
+ this.count = 0;
+ this.$el.removeClass('NB-visible NB-loading');
+ },
+
+ set_loading: function (loading) {
+ this.loading = !!loading;
+ this.$el.toggleClass('NB-loading', this.loading);
+ },
+
+ _update_text: function () {
+ this.$('.NB-new-stories-indicator-count').text(this.count);
+ this.$('.NB-new-stories-indicator-text').text(this.count === 1 ? 'new story' : 'new stories');
+ },
+
+ on_click: function (e) {
+ e.preventDefault();
+ if (this.loading) return;
+ if (!this.visible || !this.count) return;
+ if (NEWSBLUR.reader && _.isFunction(NEWSBLUR.reader.load_new_stories_from_indicator)) {
+ NEWSBLUR.reader.load_new_stories_from_indicator();
+ }
+ }
+
+});
diff --git a/media/js/newsblur/views/story_titles_view.js b/media/js/newsblur/views/story_titles_view.js
index 09c11ccc63..b7c1d6b817 100644
--- a/media/js/newsblur/views/story_titles_view.js
+++ b/media/js/newsblur/views/story_titles_view.js
@@ -325,7 +325,13 @@ NEWSBLUR.Views.StoryTitlesView = Backbone.View.extend({
var pane_anchor = this.options.pane_anchor;
var story_layout = this.options.override_layout ||
NEWSBLUR.assets.view_setting(NEWSBLUR.reader.active_feed, 'layout');
- var stories = _.compact(_.map(this.collection.models.slice(-1 * options.added), function (story) {
+ // story_titles_view.js: When prepending (new-stories indicator click),
+ // the freshly-inserted models live at the START of the collection.
+ var prepending = options.prepend;
+ var source_models = prepending ?
+ this.collection.models.slice(0, options.added) :
+ this.collection.models.slice(-1 * options.added);
+ var stories = _.compact(_.map(source_models, function (story) {
if (story.story_title_view) return;
return new NEWSBLUR.Views.StoryTitleView({
model: story,
@@ -344,18 +350,25 @@ NEWSBLUR.Views.StoryTitlesView = Backbone.View.extend({
in_add_site_view: in_add_site_view
}).render();
}));
- this.stories = this.stories.concat(stories);
var $stories = _.map(stories, function (story) {
return story.el;
});
- this.$el.append($stories);
+ if (prepending) {
+ this.stories = stories.concat(this.stories);
+ this.$el.prepend($stories);
+ } else {
+ this.stories = this.stories.concat(stories);
+ this.$el.append($stories);
+ }
if (this.options.on_dashboard || this.options.on_discover_feed || this.options.on_discover_story || this.options.on_trending_feed) {
var $extras = this.$el.find('.NB-story-title-container .NB-story-title:not(.NB-hidden)').slice(5);
$extras.addClass('NB-hidden');
}
}
this.end_loading();
- this.fill_out();
+ if (!options.prepend) {
+ this.fill_out();
+ }
},
clear: function () {