Keep the chat on a far-back summary card after its anchor loads
Publish plugin / publish (push) Successful in 3s

With an offset the newest analyzed message is usually outside the loaded
window, so placement asks the chat to load it first. That path could leave the
view away from the card while offset 0 always worked:

- Placement gave up after ~3 s (25 x 120 ms) and asked for the anchor only
  once. scrollToMessageId drops the request while waitingForGetDifference, and
  a load can be superseded. It now waits ~10 s and re-requests every ~2 s.
- Loading a far anchor makes ChatActivity run its own postponed scroll, and a
  cache-then-server reload can re-scroll or replace the list. Either can land
  after the jump. Settle checks at 0.25/0.7/1.5/3 s re-jump if the card row is
  outside the visible range, and re-place the card if a reload dropped it.
  They stop as soon as the user drags the list.

Also logs each run's count/offset, the fetched id range, pre-stream failures,
load requests, placement and jumps to the diagnostics file, since bulletin-only
errors previously left no trace.

Verified on device with count 1000, offset 1000: anchor loaded on the first
retry, card placed after it and the chat jumped there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
lyuksovannyyandClaude Opus 5.5 committed 2026-10-09 12:52:24 +02:00
1 parent 16bf8de84c
commit af23d2e682
5 files changed
+219 -13

No files matched your search

+106 -5
View File
@@ -16,7 +16,13 @@ Verified against exteraGram 12.5.x (dex inspection):
from typing import Any, Optional
import diagnostics
from config import PLACEMENT_RETRIES, PLACEMENT_RETRY_DELAY_MS, SUMMARY_HASHTAG
from config import (
JUMP_SETTLE_DELAYS_MS,
PLACEMENT_RELOAD_EVERY,
PLACEMENT_RETRIES,
PLACEMENT_RETRY_DELAY_MS,
SUMMARY_HASHTAG,
)
from services.rich_markdown import build_rich_message, hashtag_block, markdown_to_plain
# Above Telegram's issued id range, so this user exists only locally.
@@ -379,10 +385,18 @@ class SummaryInjection:
else:
index = find_index_for_anchor(messages_list, self.anchor_msg_id)
if index < 0:
if retries_left == PLACEMENT_RETRIES:
attempt = PLACEMENT_RETRIES - retries_left
if retries_left > 0 and attempt % PLACEMENT_RELOAD_EVERY == 0:
# The analyzed range ends outside the loaded window
# (an offset, or history scrolled away). Have the chat
# load and show it, then place once it is in the list.
# Asked again periodically: scrollToMessageId drops the
# request outright while waitingForGetDifference, and
# a load can be superseded by another one.
diagnostics.note(
f"injector: anchor {self.anchor_msg_id} not loaded "
f"({_id_span(messages_list)}), requesting it (attempt {attempt})"
)
self._load_anchor()
if retries_left > 0:
_on_ui(lambda: self._place(retries_left - 1), PLACEMENT_RETRY_DELAY_MS)
@@ -398,16 +412,87 @@ class SummaryInjection:
messages_list.set(index, self.message_object)
self.placed = True
self._placing = False
diagnostics.note(
f"injector: card placed after anchor {self.anchor_msg_id} at index {index} "
f"(retries left {retries_left})"
)
adapter = get_chat_adapter(self.chat_activity)
if adapter is not None:
adapter.notifyDataSetChanged()
# Once, on placement only -- scrolling on every streaming update
# would fight the user for control of the list.
self.scroll_into_view()
# On placement only -- scrolling on every streaming update would
# fight the user for control of the list. The settle checks right
# after only undo scrolls the app itself makes in that window.
if self.scroll_into_view():
self._schedule_settle(0)
except Exception as exc:
self._placing = False
diagnostics.record_error("injector.place", exc)
def _schedule_settle(self, step: int) -> None:
if step >= len(JUMP_SETTLE_DELAYS_MS):
return
previous = JUMP_SETTLE_DELAYS_MS[step - 1] if step else 0
_on_ui(lambda: self._settle(step), JUMP_SETTLE_DELAYS_MS[step] - previous)
def _settle(self, step: int) -> None:
"""Re-jumps if something scrolled the fresh card off screen.
Stops for good as soon as the user touches the list: from then on
where the chat is scrolled is theirs to decide.
"""
try:
list_view = _chat_list_view(self.chat_activity)
if list_view is not None and int(list_view.getScrollState()) == 1: # SCROLL_STATE_DRAGGING
diagnostics.note("injector: user is scrolling, jump settled")
return
span = self._visible_span()
if span is not None:
position, first, last = span
if not first <= position <= last:
diagnostics.note(
f"injector: card row {position} scrolled away (visible {first}..{last}), "
f"jumping back (check {step})"
)
self._jump_to_card()
elif self.placed and find_existing(get_messages_list(self.chat_activity), self.virtual_id) < 0:
# The list was reloaded under the card. Put it back.
messages_list = get_messages_list(self.chat_activity)
index = find_index_for_anchor(messages_list, self.anchor_msg_id)
if index >= 0:
diagnostics.note(f"injector: list reloaded, re-placing card (check {step})")
messages_list.add(index, self.message_object)
adapter = get_chat_adapter(self.chat_activity)
if adapter is not None:
adapter.notifyDataSetChanged()
self._jump_to_card()
except Exception as exc:
diagnostics.record_error("injector.settle", exc)
self._schedule_settle(step + 1)
def _visible_span(self) -> Optional[tuple]:
"""(card adapter position, first visible, last visible), or None."""
try:
from hook_utils import get_private_field
messages_list = get_messages_list(self.chat_activity)
adapter = get_chat_adapter(self.chat_activity)
layout_manager = getattr(self.chat_activity, "chatLayoutManager", None) or get_private_field(
self.chat_activity, "chatLayoutManager"
)
if messages_list is None or adapter is None or layout_manager is None:
return None
index = find_existing(messages_list, self.virtual_id)
if index < 0:
return None
first = int(layout_manager.findFirstVisibleItemPosition())
last = int(layout_manager.findLastVisibleItemPosition())
if first < 0 or last < 0:
return None
return int(adapter.messagesStartRow) + index, min(first, last), max(first, last)
except Exception as exc:
diagnostics.record_error("injector.visible_span", exc)
return None
def _load_anchor(self) -> None:
"""Asks the chat to load the anchor and scroll to it.
@@ -464,6 +549,7 @@ class SummaryInjection:
# bottom=False: offset counts from the top edge, as in
# ChatActivity.scrollToMessageId's own scrollToPosition call.
layout_manager.scrollToPositionWithOffset(position, offset, False)
diagnostics.note(f"injector: jump to row {position} offset {offset}")
return True
except Exception as exc:
diagnostics.record_error("injector.jump_to_card", exc)
@@ -583,6 +669,21 @@ def _chat_list_view(chat_activity: Any) -> Optional[Any]:
return None
def _id_span(messages_list: Any) -> str:
"""'loaded ids A..B, N rows' for diagnostics."""
try:
ids = [
int(getattr(getattr(messages_list.get(i), "messageOwner", None), "id", 0) or 0)
for i in range(messages_list.size())
]
real = [i for i in ids if i > 0]
if not real:
return f"no messages, {len(ids)} rows"
return f"loaded ids {min(real)}..{max(real)}, {len(ids)} rows"
except Exception:
return "loaded ids unknown"
def remove_cards(chat_activity: Any, virtual_ids) -> None:
"""Drops injected cards from an open chat's list.