Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion ansible/group_vars/staging.yml
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
---
git_branch: mcp-server
git_branch: main
disable_consul_services_ie_staging: yes
62 changes: 62 additions & 0 deletions apps/reader/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
106 changes: 106 additions & 0 deletions apps/reader/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
39 changes: 38 additions & 1 deletion apps/reader/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down
22 changes: 22 additions & 0 deletions media/css/reader/darkmode.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
88 changes: 88 additions & 0 deletions media/css/reader/reader.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
10 changes: 10 additions & 0 deletions media/js/newsblur/common/assetmodel.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
Loading
Loading