2 Commits
Author SHA1 Message Date
lyuksovannyyandClaude Opus 5.5 5e4c2f2a6f Message Logger: deleted dates of logged photos, open them without a photo
Publish plugin / publish (push) Successful in 4s
- The photo viewer's subtitle of a logged photo the chat no longer has
  says when it was deleted and set.
- Tapping the avatar of a chat without a photo opens the viewer on its
  photos, the logged ones included.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 04:16:42 +02:00
lyuksovannyyandClaude Opus 5.5 8535bde5ae Message Logger: trash icon under the profile menu on logged photos
Publish plugin / publish (push) Successful in 4s
The expanded profile gallery shows a red trash icon under the menu button
while it shows a logged photo the chat no longer has.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 02:12:59 +02:00
2 changed files with 215 additions and 3 deletions

No files matched your search

+1 -1
View File
@@ -3,7 +3,7 @@
"name": "Message Logger",
"description": "Shows deleted and edited messages and their edit history from your telegram-panel message logger.",
"author": "@lyuksovannyy",
"version": "0.1.4",
"version": "0.1.6",
"icon": "exteraGramPluginIcons/4",
"app_version": ">=12.5.1",
"sdk_version": ">=1.4.4.3",
+214 -2
View File
@@ -29,6 +29,9 @@ from views.history import _epoch
CONTROLLER = "org.telegram.messenger.MessagesController"
THUMBS = "org.telegram.ui.Components.GroupedPhotosListView"
VIEWER = "org.telegram.ui.PhotoViewer"
GALLERY = "org.telegram.ui.Components.ProfileGalleryView"
OVERLAYS = "org.telegram.ui.ProfileActivity$OverlaysView"
_MARK_TAG = "message_logger_trash"
NOTIFICATIONS = "org.telegram.messenger.NotificationCenter"
# A logged photo whose id isn't Telegram's (logged by a bot) is the chat's
# own when one of the chat's photos was set this close to its post.
@@ -87,6 +90,9 @@ class Avatars:
self._appending = False
self._updates = {} # (account, dialog) -> args of its last dialogPhotosUpdate
self._icon = None
self._dates = {} # logged photo id -> (set at, deleted at), ISO strings
self._watched = {} # gallery hash -> its page listener (kept alive)
self._position = None # ProfileGalleryView.getRealPosition
self._shown = None # PhotoViewer.isVisible()
def install(self):
@@ -99,6 +105,35 @@ class Avatars:
except Exception as exc:
diag.error("avatars.photos", exc)
class AfterSetData(MethodHook):
def after_hooked_method(self, param):
try:
avatars.watch_gallery(param.thisObject)
except Exception as exc:
diag.error("avatars.gallery", exc)
class AfterOverlays(MethodHook):
def after_hooked_method(self, param):
try:
avatars.update_profile(field(param.thisObject, "this$0"))
except Exception as exc:
diag.error("avatars.profile", exc)
class AfterSwitch(MethodHook):
def after_hooked_method(self, param):
try:
avatars.viewer_dates(param.thisObject, int(param.args[0]))
except Exception as exc:
diag.error("avatars.dates", exc)
class BeforeOpen(MethodHook):
def before_hooked_method(self, param):
try:
if avatars.open_without_photo(param.thisObject):
param.setResult(None)
except Exception as exc:
diag.error("avatars.open", exc)
class AfterThumbs(MethodHook):
def after_hooked_method(self, param):
try:
@@ -115,6 +150,15 @@ class Avatars:
self.handles += hook_methods(self.plugin, CONTROLLER, "getDialogPhotos", AfterPhotos())
self.handles += hook_methods(self.plugin, THUMBS, "onDraw", AfterThumbs())
self.handles += hook_methods(self.plugin, GALLERY, "setData", AfterSetData())
self.handles += hook_methods(
self.plugin, VIEWER, "setIsAboutToSwitchToIndex", AfterSwitch()
)
self.handles += hook_methods(
self.plugin, "org.telegram.ui.ProfileActivity", "openAvatar", BeforeOpen()
)
for name in ("onDraw", "setAlphaValue"):
self.handles += hook_methods(self.plugin, OVERLAYS, name, AfterOverlays())
self.handles += hook_methods(
self.plugin, NOTIFICATIONS, "postNotificationName", BeforeNotify()
)
@@ -171,6 +215,7 @@ class Avatars:
recorded.append(
(photo, int(original) if original else None, _epoch(avatar.get("date")))
)
self._dates[int(photo.id)] = (avatar.get("date"), avatar.get("deleted_at"))
with self._lock:
self._recorded[(account, dialog_id)] = recorded
self._loaded_at[(account, dialog_id)] = time.monotonic()
@@ -227,14 +272,14 @@ class Avatars:
field(viewer, "avatarsDialogId") or 0
) == int(dialog_id)
def _append(self, account, dialog_id):
def _append(self, account, dialog_id, force=False):
"""Puts the logged photos into the chat's photos and tells the
gallery, at most every few seconds (it reads the photos again)."""
from org.telegram.messenger import MessagesController, NotificationCenter
if not self.handles:
return # unloaded
if self._viewing(dialog_id):
if not force and self._viewing(dialog_id):
on_ui(lambda: self._append(account, dialog_id), 1000) # once it closed
return
self._appending = True # getDialogPhotos calls back into photos_wanted
@@ -344,6 +389,173 @@ class Avatars:
icon.setBounds(right - size, bottom - size, right, bottom)
icon.draw(canvas)
# the profile's trash icon (under the menu button)
def watch_gallery(self, gallery):
"""Redraws the icon as the profile's gallery changes page."""
key = int(gallery.hashCode())
if key in self._watched:
return
from java import dynamic_proxy, jclass
listener_class = jclass("androidx.viewpager.widget.ViewPager$OnPageChangeListener")
avatars = self
class Pages(dynamic_proxy(listener_class)):
def onPageScrolled(self, position, offset, pixels):
pass
def onPageSelected(self, position):
on_ui(lambda: avatars.update_profile(field(gallery, "this$0")))
def onPageScrollStateChanged(self, state):
pass
listener = Pages()
gallery.addOnPageChangeListener(listener)
self._watched[key] = listener
def _current_photo(self, gallery):
if self._position is None:
method = java_class(GALLERY).getDeclaredMethod("getRealPosition")
method.setAccessible(True)
self._position = method
photos = field(gallery, "photos")
index = int(self._position.invoke(gallery))
if photos is None or not 0 <= index < photos.size():
return None
return photos.get(index)
def update_profile(self, profile):
"""Shows the icon while the expanded gallery shows a logged photo."""
if profile is None or not self._ids:
return
gallery = field(profile, "avatarsViewPager")
if gallery is None:
return
photo = self._current_photo(gallery)
shown = (
photo is not None
and int(photo.id) in self._ids
and bool(field(profile, "isPulledDown"))
)
mark = self._profile_mark(profile, create=shown)
if mark is None:
return
from android.view import View
mark.setVisibility(View.VISIBLE if shown else View.GONE)
mark.setAlpha(float(field(profile, "currentExpandAnimatorValue") or 1.0))
def _profile_mark(self, profile, create):
root = field(profile, "fragmentView")
if root is None or not hasattr(root, "addView"):
return None
for index in range(root.getChildCount()):
child = root.getChildAt(index)
if str(child.getTag()) == _MARK_TAG:
return child
if not create:
return None
from android.view import Gravity
from android.widget import FrameLayout, ImageView
from java import jint
from org.telegram.messenger import AndroidUtilities
from org.telegram.ui.ActionBar import ActionBar, Theme
from android.graphics import PorterDuff, PorterDuffColorFilter
from org.telegram.messenger import R
from hooks.render import _trash_drawable
dp = AndroidUtilities.dp
icon = _trash_drawable(R.drawable.msg_delete)
red = jint(Theme.getColor(Theme.key_text_RedBold))
icon.setColorFilter(PorterDuffColorFilter(red, PorterDuff.Mode.SRC_IN))
mark = ImageView(root.getContext())
mark.setTag(_MARK_TAG)
mark.setImageDrawable(icon)
mark.setPadding(dp(8), dp(8), dp(8), dp(8))
mark.setBackground(Theme.createCircleDrawable(dp(36), jint(0x66000000)))
params = FrameLayout.LayoutParams(dp(36), dp(36))
params.gravity = Gravity.TOP | Gravity.RIGHT
params.topMargin = AndroidUtilities.statusBarHeight + ActionBar.getCurrentActionBarHeight()
params.rightMargin = dp(10)
root.addView(mark, params)
return mark
# dates in the photo viewer
def viewer_dates(self, viewer, index):
"""A logged photo's subtitle: when it was deleted and set (instead of
its upload to the log group)."""
photos = field(viewer, "avatarsArr")
if photos is None or not 0 <= index < photos.size():
return
photo = photos.get(index)
dates = self._dates.get(int(photo.id)) if photo is not None else None
container = field(viewer, "actionBarContainer")
if dates is None or container is None:
return
from views.history import _when
set_at, deleted_at = dates
parts = [f"deleted {_when(deleted_at)}" if deleted_at else "deleted"]
if set_at:
parts.append(f"set {_when(set_at)}")
text = " · ".join(parts)
container.setSubtitle(text[:1].upper() + text[1:])
# a chat without a photo
def open_without_photo(self, profile):
"""openAvatar of a chat that has no photo but logged ones: the photo
viewer opens on the newest logged photo (with the rest)."""
from org.telegram.messenger import ImageLocation, MessagesController
from org.telegram.tgnet import TLRPC
from org.telegram.ui import PhotoViewer
account = int(profile.getCurrentAccount())
controller = MessagesController.getInstance(account)
user_id, chat_id = int(field(profile, "userId") or 0), int(field(profile, "chatId") or 0)
if user_id:
peer, dialog_id = controller.getUser(user_id), user_id
empty = TLRPC.TL_userProfilePhotoEmpty
else:
peer, dialog_id = controller.getChat(chat_id), -chat_id
empty = TLRPC.TL_chatPhotoEmpty
photo = getattr(peer, "photo", None) if peer is not None else None
if photo is not None and not isinstance(photo, empty):
return False # the app opens it
recorded = self._recorded.get((account, dialog_id))
if not recorded:
controller.getDialogPhotos(dialog_id) # loads the logged ones
return False
# The newest of the chat's photos (its own, then the logged ones).
holder = controller.getDialogPhotos(dialog_id)
if holder is not None:
self._add_to(account, dialog_id, holder)
listed = java_list(field(holder, "photos")) if holder is not None else []
first = next((p for p in listed if p is not None), recorded[0][0])
size = media_share.photo_size(first, 800)
if size is None:
return False
viewer = PhotoViewer.getInstance()
viewer.setParentActivity(profile)
viewer.openPhoto(
size.location, ImageLocation.getForPhoto(size, first), field(profile, "provider")
)
# The profile names no chat for a photo it doesn't show itself: the
# viewer is told, and gets the chat's photos (the logged ones).
from hook_utils import set_private_field
from java import jlong
set_private_field(viewer, "avatarsDialogId", jlong(dialog_id))
self._told.pop((account, dialog_id), None)
self._append(account, dialog_id, force=True)
return True
def _trash(self):
if self._icon is None:
from android.graphics import Paint, PorterDuff, PorterDuffColorFilter