Keep the status card visible and the unread prefill stable

Two bugs visible in a device log.

"injector: anchor 0 not loaded, skipping insert" was a regression from the
previous commit. _newest_visible_message_id returns 0 when the offset reaches
past the loaded window, and the strict anchor check then refused to place the
card, so pressing Summarize appeared to do nothing at all. Before that commit
anchor 0 silently matched a date separator, which is why it went unnoticed.

A live card is the user's only feedback that the press registered, so it must
always appear: try the exact anchor, then id ordering, then the bottom of the
chat, and let reanchor() move it once the analyzed range is known. Restoring
saved summaries stays strict, since nothing corrects their position later.
_newest_visible_message_id also falls back to the oldest loaded message rather
than returning 0.

The unread button prefilled a smaller range on each press -- 1000, then 15,
then 75, with ~1100 unread. Viewing a chat calls
MessagesController.markDialogAsRead, so dialog.unread_count decays while the
chat is open and no longer describes the backlog the user wants summarized.

Resolve the count from the unread divider instead, which is what the app's own
button uses (onPageDownClicked branches on createUnreadMessageAfterId) and
which does not decay while the chat stays open, and keep the largest value
seen per chat so a later press never prefills less than an earlier one.

Both are covered by tests that fail against the previous behaviour: the strict
anchor path reports inserted=True while leaving no card in the list, and the
unread resolver reproduces exactly 1100, 15, 75.
This commit is contained in:
lyuksovannyy committed 2026-09-19 17:08:11 +02:00
1 parent b05d1a274c
commit 785618233b
4 files changed
+162 -13

No files matched your search

+70 -8
View File
@@ -102,6 +102,8 @@ class UnreadHook:
self.plugin = plugin
self._unhooks: List[Any] = []
self._hooked_view_ids: set = set()
# dialog_id -> largest unread count seen while this chat stayed open.
self._peak_unread: dict = {}
def install(self) -> None:
try:
@@ -307,14 +309,13 @@ class UnreadHook:
diagnostics.record_error("unread_hook._trigger", RuntimeError("could not resolve dialog_id"))
return
raw_unread = 0
try:
controller = get_messages_controller()
dialog = controller.dialogs_dict.get(dialog_id) if controller else None
if dialog is not None:
raw_unread = int(getattr(dialog, "unread_count", 0) or 0)
except Exception:
raw_unread = 0
# unread_count decays while the chat is open: viewing messages calls
# MessagesController.markDialogAsRead, so by a second press it can
# report 15 where the first press saw ~1100. Prefer the unread
# divider, which is what the app's own button uses
# (onPageDownClicked branches on createUnreadMessageAfterId) and
# which does not decay while the chat stays open.
raw_unread = self._resolve_unread_count(fragment, dialog_id)
if raw_unread > 0:
count = min(MAX_MSG_COUNT, max(1, raw_unread))
@@ -330,6 +331,67 @@ class UnreadHook:
except Exception as exc:
diagnostics.record_error("unread_hook._trigger", exc)
def _resolve_unread_count(self, fragment: Any, dialog_id: int) -> int:
"""Unread message count that stays stable while the chat is open.
Three sources, best first:
1. The unread divider (`createUnreadMessageAfterId`): the id the app's
own pagedown button scrolls to. Counting loaded messages newer than
it gives the real backlog and does not shrink as messages are read.
2. `dialog.unread_count`: accurate on first open, but
MessagesController.markDialogAsRead decrements it while the chat is
viewed, so a second press saw 15 instead of ~1100.
3. The largest value seen earlier for this chat, so a later press never
prefills less than an earlier one.
"""
from client_utils import get_messages_controller
candidates = []
divider_id = 0
try:
divider_id = int(getattr(fragment, "createUnreadMessageAfterId", 0) or 0)
except Exception:
divider_id = 0
if divider_id > 0:
try:
from services.rich_injector import get_messages_list
messages_list = get_messages_list(fragment)
if messages_list is not None:
newer = 0
for i in range(messages_list.size()):
owner = getattr(messages_list.get(i), "messageOwner", None)
msg_id = int(getattr(owner, "id", 0) or 0) if owner is not None else 0
# Injected cards are negative and date separators are 0.
if msg_id > 0 and msg_id >= divider_id:
newer += 1
if newer > 0:
candidates.append(newer)
except Exception as exc:
diagnostics.record_error("unread_hook.divider_count", exc)
try:
controller = get_messages_controller()
dialog = controller.dialogs_dict.get(dialog_id) if controller else None
if dialog is not None:
candidates.append(int(getattr(dialog, "unread_count", 0) or 0))
except Exception as exc:
diagnostics.record_error("unread_hook.dialog_unread", exc)
previous = int(self._peak_unread.get(dialog_id, 0) or 0)
resolved = max(candidates + [previous]) if candidates or previous else 0
if resolved > 0:
if len(self._peak_unread) > 64:
self._peak_unread.clear()
self._peak_unread[dialog_id] = resolved
diagnostics.note(
f"unread_hook: unread divider={divider_id} candidates={candidates} "
f"previous={previous} resolved={resolved}"
)
return resolved
@staticmethod
def _resolve_dialog_id(fragment: Any) -> Optional[int]:
try:
+10
View File
@@ -298,6 +298,13 @@ class ChatSummariesPlugin(BasePlugin):
`skip` mirrors the summarize offset, so with an offset the guess lands
where the analyzed range actually ends instead of at the bottom of the
chat. Injected cards carry negative ids and are ignored.
When the offset reaches past the loaded window there is no message to
skip to, so fall back to the oldest loaded message rather than 0: the
card only needs somewhere valid to appear while "Fetching messages…" is
shown, and reanchor() moves it to the exact spot once the range is
known. Returning 0 would make the insert fail and the user would click
Summarize and see nothing happen.
"""
try:
from services.rich_injector import get_messages_list
@@ -306,15 +313,18 @@ class ChatSummariesPlugin(BasePlugin):
if messages_list is None:
return 0
remaining = max(0, int(skip))
oldest = 0
for i in range(messages_list.size()):
owner = getattr(messages_list.get(i), "messageOwner", None)
msg_id = int(getattr(owner, "id", 0) or 0) if owner is not None else 0
if msg_id <= 0:
continue
oldest = msg_id
if remaining:
remaining -= 1
continue
return msg_id
return oldest
except Exception as exc:
diagnostics.record_error("_newest_visible_message_id", exc)
return 0
+20 -5
View File
@@ -316,14 +316,29 @@ class SummaryInjection:
if existing >= 0:
messages_list_now.set(existing, message_object)
else:
# A live card is a direct response to the user pressing
# Summarize, so it must become visible even when the
# provisional anchor is not loaded (a large offset reaches
# past the loaded window). Fall back to id ordering, which
# still keeps every newer message below the card, and let
# reanchor() move it to the exact spot once the analyzed
# range is known. Restoring saved summaries stays strict --
# there is no follow-up correction there.
index = find_index_for_anchor(messages_list_now, self.anchor_msg_id)
if index < 0:
# Anchor not loaded in this view: placing the card by id
# order alone would put it next to unrelated messages.
diagnostics.note(
f"injector: anchor {self.anchor_msg_id} not loaded, skipping insert"
index = find_index_for_anchor(
messages_list_now, self.anchor_msg_id, require_anchor=False
)
return
if index < 0:
# No anchor at all (nothing loaded yet, or the offset
# reached past everything). Show the card at the bottom,
# where the user is looking after pressing Summarize;
# reanchor() relocates it once the range is known.
diagnostics.note(
f"injector: anchor {self.anchor_msg_id} unresolved, "
"showing status card at the bottom"
)
index = 0
messages_list_now.add(index, message_object)
adapter = get_chat_adapter(self.chat_activity)
if adapter is not None:
+62
View File
@@ -794,6 +794,50 @@ def main(argv=None) -> int:
f"installed hooks: {len(plugin.unread_hook._unhooks)} (expected 3)",
)
# unread_count decays while a chat is open (markDialogAsRead runs as
# messages are viewed), so pressing the unread button a second time used
# to prefill a far smaller range than the first press: 1100 then 15 then
# 75. The resolved count must not shrink while the chat stays open.
class _UnreadDialog:
def __init__(self, count):
self.unread_count = count
class _UnreadFragment:
def __init__(self, divider, ids):
self.createUnreadMessageAfterId = divider
self.messages = _JavaList()
for mid in ids:
owner_obj = _TLObj("TL_message")
owner_obj.id = mid
self.messages.add(_FakeMessageObject(0, owner_obj))
unread_dialog = _UnreadDialog(1100)
cu_mod = sys.modules["client_utils"]
real_controller = cu_mod.get_messages_controller
class _UnreadController:
class dialogs_dict:
@staticmethod
def get(_dialog_id):
return unread_dialog
cu_mod.get_messages_controller = lambda: _UnreadController
try:
unread_fragment = _UnreadFragment(171000, list(range(172700, 172670, -1)))
resolved = []
for decayed in (1100, 15, 75):
unread_dialog.unread_count = decayed
resolved.append(
plugin.unread_hook._resolve_unread_count(unread_fragment, -1978105175)
)
finally:
cu_mod.get_messages_controller = real_controller
check(
"the unread prefill does not shrink as messages are marked read",
resolved == [1100, 1100, 1100],
f"resolved={resolved}",
)
settings_rows = plugin.create_settings()
settings_keys = {row.key for row in settings_rows if hasattr(row, "key")}
expected_keys = {
@@ -1971,6 +2015,24 @@ def main(argv=None) -> int:
f"dispatched={sep_n} ids={sep_ids}",
)
# A live status card is the only feedback that pressing Summarize did
# anything, so it must appear even when the provisional anchor cannot be
# placed: a large offset reaches past the loaded window, and an
# unresolved anchor arrives as 0. Requiring a loaded anchor here left the
# user staring at an unchanged chat.
for label, anchor in (("unresolved anchor 0", 0), ("anchor past the window", 4242)):
live = _FakeChatActivity([700, 690, 680])
live_injection = injector.SummaryInjection(
live, 0, -4242, 0, anchor, injector.virtual_id_for(0, anchor or 1)
)
shown = live_injection.insert(status="Fetching messages…", streaming=True)
live_ids = [m.messageOwner.id for m in live.messages]
check(
f"the status card still appears with an {label}",
shown and len([i for i in live_ids if i < 0]) == 1,
f"inserted={shown} ids={live_ids}",
)
check(
"sys.modules gained no entry for bundled plugin module names",
not ({"main", "config", "services", "providers", "hooks", "ui.summarize_dialog"} & new_modules),