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 () {