generated from exteraGramPlugins/pluginBase
Message Logger: keep own deletions, remove from view, reset marks
Publish plugin / publish (push) Successful in 4s
Publish plugin / publish (push) Successful in 4s
- Delete dialog: "Keep message for myself" checkbox keeps your deletion on this device (marked deleted); unticked, the message is hidden and never restored. Already deleted messages get a plain "Remove from view" dialog (fixes the chat staying dimmed/touch-blocked after it). - "Reset deleted/edited marks" in the chat menu (Plugins) and, for all chats, in the plugin settings. - Fix restored text-only messages showing as empty bubbles (rich text wrappers like links lost their text); missing settings icons. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
bbe4a557a8
commit
0176f7099d
9 files changed
+586
-12
No files matched your search
@@ -20,6 +20,11 @@ the device, so a chat opened again, even offline, shows them at once.
|
|||||||
- **Deleted messages stay.** Messages others delete remain in the chat at 75% opacity with a
|
- **Deleted messages stay.** Messages others delete remain in the chat at 75% opacity with a
|
||||||
red trash icon (from the active icon pack) in the time row. Messages deleted while the app
|
red trash icon (from the active icon pack) in the time row. Messages deleted while the app
|
||||||
was closed are confirmed with Telegram and restored from the logger copy.
|
was closed are confirmed with Telegram and restored from the logger copy.
|
||||||
|
- **Keep your own deletions.** The delete dialog gets a *Keep message for myself* checkbox: ticked,
|
||||||
|
the deletion goes to Telegram but the message stays on this device, marked deleted.
|
||||||
|
Unticked (the default), it is deleted as usual and never restored from the logger here.
|
||||||
|
Deleting a message that is already deleted shows a plain dialog that removes it from view
|
||||||
|
on this device only.
|
||||||
- **Edit history.** *Edit history* in the message menu (right above Forward, for edited
|
- **Edit history.** *Edit history* in the message menu (right above Forward, for edited
|
||||||
messages) opens every version as a mock chat, also for the logger groups' own posts, with
|
messages) opens every version as a mock chat, also for the logger groups' own posts, with
|
||||||
an *Open topic* button for the history topic in the activity group.
|
an *Open topic* button for the history topic in the activity group.
|
||||||
@@ -29,6 +34,9 @@ the device, so a chat opened again, even offline, shows them at once.
|
|||||||
- **Links into chats you're not in.** Message links into a chat the current account isn't
|
- **Links into chats you're not in.** Message links into a chat the current account isn't
|
||||||
in (e.g. a log post's "Message" link) open that chat as a signed-in account that is in it;
|
in (e.g. a log post's "Message" link) open that chat as a signed-in account that is in it;
|
||||||
going back returns to your account.
|
going back returns to your account.
|
||||||
|
- **Reset marks.** *Reset deleted/edited marks* in the chat's menu (Plugins) forgets that
|
||||||
|
chat's cached marks and checks it with the server again; the same row in the plugin
|
||||||
|
settings does it for every chat. Messages you removed from view stay hidden.
|
||||||
- **Cache lifetime.** Cached answers are kept for 1, 7, 30, 90 or 365 days, or forever
|
- **Cache lifetime.** Cached answers are kept for 1, 7, 30, 90 or 365 days, or forever
|
||||||
(30 by default).
|
(30 by default).
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@
|
|||||||
"name": "Message Logger",
|
"name": "Message Logger",
|
||||||
"description": "Shows deleted and edited messages and their edit history from your telegram-panel message logger.",
|
"description": "Shows deleted and edited messages and their edit history from your telegram-panel message logger.",
|
||||||
"author": "@lyuksovannyy",
|
"author": "@lyuksovannyy",
|
||||||
"version": "0.1.1",
|
"version": "0.1.2",
|
||||||
"icon": "exteraGramPluginIcons/4",
|
"icon": "exteraGramPluginIcons/4",
|
||||||
"app_version": ">=12.5.1",
|
"app_version": ">=12.5.1",
|
||||||
"sdk_version": ">=1.4.4.3",
|
"sdk_version": ">=1.4.4.3",
|
||||||
|
|||||||
+80
-3
@@ -115,6 +115,13 @@ def _loaded(fragment, message_id):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def jint_index(index):
|
||||||
|
"""ArrayList.remove(int index), not remove(Object)."""
|
||||||
|
from java import jint
|
||||||
|
|
||||||
|
return jint(index)
|
||||||
|
|
||||||
|
|
||||||
def _android_peer_id(marked):
|
def _android_peer_id(marked):
|
||||||
"""Telethon sender id -> Android peer id (channels are -id)."""
|
"""Telethon sender id -> Android peer id (channels are -id)."""
|
||||||
marked = int(marked)
|
marked = int(marked)
|
||||||
@@ -141,10 +148,13 @@ def _rich_text(node):
|
|||||||
value = getattr(node, "text", None)
|
value = getattr(node, "text", None)
|
||||||
if isinstance(value, str):
|
if isinstance(value, str):
|
||||||
return value
|
return value
|
||||||
|
if value is not None: # a wrapper (url, bold, spoiler...): its inner text
|
||||||
|
return _rich_text(value)
|
||||||
|
# textConcat; every RichText carries `texts`, empty unless it concatenates
|
||||||
texts = getattr(node, "texts", None)
|
texts = getattr(node, "texts", None)
|
||||||
if texts is not None and hasattr(texts, "size"):
|
if texts is not None and hasattr(texts, "size"):
|
||||||
return "".join(_rich_text(t) for t in java_list(texts))
|
return "".join(_rich_text(t) for t in java_list(texts))
|
||||||
return _rich_text(value)
|
return ""
|
||||||
|
|
||||||
|
|
||||||
def _rich_plain_text(rich):
|
def _rich_plain_text(rich):
|
||||||
@@ -347,6 +357,8 @@ class ChatSync:
|
|||||||
def _restore(self, fragment, account, dialog_id, chat_id, me, states):
|
def _restore(self, fragment, account, dialog_id, chat_id, me, states):
|
||||||
for state in states:
|
for state in states:
|
||||||
message_id = int(state["message_id"])
|
message_id = int(state["message_id"])
|
||||||
|
if store.is_hidden(account, dialog_id, message_id):
|
||||||
|
continue # the user removed it from view
|
||||||
key = (account, dialog_id, message_id)
|
key = (account, dialog_id, message_id)
|
||||||
if key in self._restoring:
|
if key in self._restoring:
|
||||||
continue
|
continue
|
||||||
@@ -422,7 +434,7 @@ class ChatSync:
|
|||||||
if strip:
|
if strip:
|
||||||
_strip_rich_header(rich)
|
_strip_rich_header(rich)
|
||||||
plain = _rich_plain_text(rich)
|
plain = _rich_plain_text(rich)
|
||||||
if plain is not None:
|
if plain: # never a blank bubble: else it stays rich
|
||||||
# Text only: a normal bubble, as the original was.
|
# Text only: a normal bubble, as the original was.
|
||||||
msg.message = plain
|
msg.message = plain
|
||||||
msg.entities = ArrayList()
|
msg.entities = ArrayList()
|
||||||
@@ -495,7 +507,72 @@ class ChatSync:
|
|||||||
if fragment is not None:
|
if fragment is not None:
|
||||||
on_ui(lambda: self.schedule(fragment), delay_ms)
|
on_ui(lambda: self.schedule(fragment), delay_ms)
|
||||||
|
|
||||||
def refresh_dialog(self, account, dialog_id, ids):
|
def _open_chat(self, account, dialog_id):
|
||||||
|
"""The ChatActivity showing the dialog: the one last synced, else the
|
||||||
|
screen on top when it is that chat."""
|
||||||
fragment = self._fragments.get((account, dialog_id))
|
fragment = self._fragments.get((account, dialog_id))
|
||||||
|
if fragment is not None:
|
||||||
|
return fragment
|
||||||
|
from client_utils import get_last_fragment
|
||||||
|
|
||||||
|
top = get_last_fragment()
|
||||||
|
try:
|
||||||
|
if (
|
||||||
|
top is not None
|
||||||
|
and int(top.getCurrentAccount()) == account
|
||||||
|
and int(top.getDialogId()) == dialog_id
|
||||||
|
):
|
||||||
|
return top
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
def refresh_dialog(self, account, dialog_id, ids):
|
||||||
|
fragment = self._open_chat(account, dialog_id)
|
||||||
if fragment is not None:
|
if fragment is not None:
|
||||||
on_ui(lambda: self.refresh(fragment, set(int(i) for i in ids)))
|
on_ui(lambda: self.refresh(fragment, set(int(i) for i in ids)))
|
||||||
|
|
||||||
|
def reset(self, account=None, dialog_id=None):
|
||||||
|
"""Forgets the cache of a chat (all chats when None) and redraws and
|
||||||
|
re-checks the open one(s). Returns the number of marks forgotten."""
|
||||||
|
removed = store.reset(account, dialog_id)
|
||||||
|
for (acc, dia), fragment in list(self._fragments.items()):
|
||||||
|
if dialog_id is not None and (acc, dia) != (account, dialog_id):
|
||||||
|
continue
|
||||||
|
|
||||||
|
def redraw(fragment=fragment):
|
||||||
|
adapter = field(fragment, "chatAdapter")
|
||||||
|
if adapter is not None:
|
||||||
|
for m in java_list(field(fragment, "messages")):
|
||||||
|
if m is not None:
|
||||||
|
m.forceUpdate = True
|
||||||
|
adapter.notifyDataSetChanged()
|
||||||
|
self.schedule(fragment)
|
||||||
|
|
||||||
|
on_ui(redraw)
|
||||||
|
return removed
|
||||||
|
|
||||||
|
def drop_from_view(self, account, dialog_id, ids):
|
||||||
|
"""Takes messages out of the open chat's list (UI thread)."""
|
||||||
|
fragment = self._open_chat(account, dialog_id)
|
||||||
|
if fragment is None:
|
||||||
|
return
|
||||||
|
wanted = set(int(i) for i in ids)
|
||||||
|
messages = field(fragment, "messages")
|
||||||
|
if messages is None:
|
||||||
|
return
|
||||||
|
removed = 0
|
||||||
|
for index in range(messages.size() - 1, -1, -1):
|
||||||
|
message = messages.get(index)
|
||||||
|
if message is not None and int(message.getId()) in wanted:
|
||||||
|
messages.remove(jint_index(index))
|
||||||
|
removed += 1
|
||||||
|
try:
|
||||||
|
by_id = field(fragment, "messagesDict")[0]
|
||||||
|
for i in wanted:
|
||||||
|
by_id.remove(int(i))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
adapter = field(fragment, "chatAdapter")
|
||||||
|
if removed and adapter is not None:
|
||||||
|
adapter.notifyDataSetChanged()
|
||||||
@@ -0,0 +1,316 @@
|
|||||||
|
"""The app's "Delete message" dialog, extended:
|
||||||
|
|
||||||
|
- a "Keep message for myself" checkbox: ticked, the deletion goes to Telegram but
|
||||||
|
the message stays on this device, marked deleted (its local removal is
|
||||||
|
filtered out of MessagesStorage.markMessagesAsDeleted and the
|
||||||
|
messagesDeleted notification; the server's echo then counts as someone
|
||||||
|
else's deletion, see hooks/deletes.py). Unticked (default), it is deleted
|
||||||
|
as usual and remembered as hidden, so it is never restored from the
|
||||||
|
logger here;
|
||||||
|
- already deleted (kept) messages get a plain dialog without checkboxes
|
||||||
|
whose button removes them from view (locally only) and hides them.
|
||||||
|
|
||||||
|
AlertsCreator.createDeleteMessagesAlert(fragment, user, chat, encryptedChat,
|
||||||
|
chatInfo, mergeDialogId, selectedMessage, selectedMessages[2], group,
|
||||||
|
topicId, mode, participants, onDelete, hideDim, resourcesProvider) builds and
|
||||||
|
shows the dialog (exteraGram 12.10.6, probed).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import time
|
||||||
|
|
||||||
|
from base_plugin import MethodHook
|
||||||
|
|
||||||
|
import diag
|
||||||
|
import store
|
||||||
|
from jutil import field, hook_methods, java_ints, java_list, on_ui
|
||||||
|
|
||||||
|
ALERTS = "org.telegram.ui.Components.AlertsCreator"
|
||||||
|
_KEEP_SECONDS = 60
|
||||||
|
|
||||||
|
|
||||||
|
def _targets(args):
|
||||||
|
"""MessageObjects the dialog is about."""
|
||||||
|
selected, arrays, group = args[6], args[7], args[8]
|
||||||
|
if selected is not None:
|
||||||
|
if group is not None:
|
||||||
|
return [m for m in java_list(group.messages) if m is not None]
|
||||||
|
return [selected]
|
||||||
|
out = []
|
||||||
|
for array in arrays or []:
|
||||||
|
if array is None:
|
||||||
|
continue
|
||||||
|
for index in range(array.size()):
|
||||||
|
out.append(array.valueAt(index))
|
||||||
|
return [m for m in out if m is not None]
|
||||||
|
|
||||||
|
|
||||||
|
def _key(message):
|
||||||
|
return int(message.currentAccount), int(message.getDialogId()), int(message.getId())
|
||||||
|
|
||||||
|
|
||||||
|
def _deleted(message):
|
||||||
|
return int(message.getId()) > 0 and store.is_deleted(*_key(message))
|
||||||
|
|
||||||
|
|
||||||
|
class DeleteDialog:
|
||||||
|
def __init__(self, plugin, chats):
|
||||||
|
self.plugin = plugin
|
||||||
|
self.chats = chats # hooks/chat.ChatSync
|
||||||
|
self.handles = []
|
||||||
|
self._choice = None # [checked] of the open dialog
|
||||||
|
self._keeping = {} # (account, id) -> monotonic time to keep until
|
||||||
|
|
||||||
|
# filtering a kept deletion out of the local removal
|
||||||
|
|
||||||
|
def keeping(self, account, ids):
|
||||||
|
now = time.monotonic()
|
||||||
|
return [i for i in ids if self._keeping.get((account, i), 0) > now]
|
||||||
|
|
||||||
|
def install(self):
|
||||||
|
dialog = self
|
||||||
|
|
||||||
|
class BeforeAlert(MethodHook):
|
||||||
|
def before_hooked_method(self, param):
|
||||||
|
try:
|
||||||
|
if dialog.before_alert(param):
|
||||||
|
param.setResult(None)
|
||||||
|
except Exception as exc:
|
||||||
|
diag.error("delete_dialog.before", exc)
|
||||||
|
|
||||||
|
def after_hooked_method(self, param):
|
||||||
|
try:
|
||||||
|
dialog.after_alert(param)
|
||||||
|
except Exception as exc:
|
||||||
|
diag.error("delete_dialog.after", exc)
|
||||||
|
|
||||||
|
class BeforeStorage(MethodHook):
|
||||||
|
def before_hooked_method(self, param):
|
||||||
|
try:
|
||||||
|
dialog.filter_storage(param)
|
||||||
|
except Exception as exc:
|
||||||
|
diag.error("delete_dialog.storage", exc)
|
||||||
|
|
||||||
|
class BeforeNotify(MethodHook):
|
||||||
|
def before_hooked_method(self, param):
|
||||||
|
try:
|
||||||
|
dialog.filter_notification(param)
|
||||||
|
except Exception as exc:
|
||||||
|
diag.error("delete_dialog.notify", exc)
|
||||||
|
|
||||||
|
self.handles += hook_methods(
|
||||||
|
self.plugin, ALERTS, "createDeleteMessagesAlert", BeforeAlert()
|
||||||
|
)
|
||||||
|
self.handles += hook_methods(
|
||||||
|
self.plugin,
|
||||||
|
"org.telegram.messenger.MessagesStorage",
|
||||||
|
"markMessagesAsDeleted",
|
||||||
|
BeforeStorage(),
|
||||||
|
)
|
||||||
|
self.handles += hook_methods(
|
||||||
|
self.plugin,
|
||||||
|
"org.telegram.messenger.NotificationCenter",
|
||||||
|
"postNotificationName",
|
||||||
|
BeforeNotify(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def uninstall(self):
|
||||||
|
for handle in self.handles:
|
||||||
|
try:
|
||||||
|
self.plugin.unhook_method(handle)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self.handles = []
|
||||||
|
|
||||||
|
# the dialog
|
||||||
|
|
||||||
|
def before_alert(self, param):
|
||||||
|
"""Already deleted messages only: our own dialog instead (True)."""
|
||||||
|
self._choice = None
|
||||||
|
targets = _targets(param.args)
|
||||||
|
if not targets or not all(_deleted(m) for m in targets):
|
||||||
|
return False
|
||||||
|
on_delete, hide_dim = param.args[12], param.args[13]
|
||||||
|
on_ui(lambda: self._hide_dialog(param.args[0], targets, on_delete, hide_dim))
|
||||||
|
return True
|
||||||
|
|
||||||
|
def after_alert(self, param):
|
||||||
|
"""Adds the "Keep message here" checkbox to the app's dialog."""
|
||||||
|
targets = _targets(param.args)
|
||||||
|
if not targets or all(_deleted(m) for m in targets):
|
||||||
|
return
|
||||||
|
dialog = param.args[0].getVisibleDialog()
|
||||||
|
if dialog is None:
|
||||||
|
return
|
||||||
|
content = _message_column(dialog.getWindow().getDecorView())
|
||||||
|
if content is None:
|
||||||
|
diag.log("delete dialog: no place for the keep checkbox")
|
||||||
|
return
|
||||||
|
from android.widget import LinearLayout
|
||||||
|
from android_utils import OnClickListener
|
||||||
|
from org.telegram.messenger import AndroidUtilities
|
||||||
|
from org.telegram.ui.Cells import CheckBoxCell
|
||||||
|
|
||||||
|
choice = [False]
|
||||||
|
self._choice = choice
|
||||||
|
cell = CheckBoxCell(content.getContext(), 1)
|
||||||
|
cell.setText("Keep message for myself", "", False, False)
|
||||||
|
|
||||||
|
def toggle(_view):
|
||||||
|
choice[0] = not choice[0]
|
||||||
|
cell.setChecked(choice[0], True)
|
||||||
|
|
||||||
|
cell.setOnClickListener(OnClickListener(toggle))
|
||||||
|
params = LinearLayout.LayoutParams(-1, -2)
|
||||||
|
params.topMargin = AndroidUtilities.dp(4)
|
||||||
|
content.addView(cell, params)
|
||||||
|
|
||||||
|
def _hide_dialog(self, fragment, targets, on_delete, hide_dim):
|
||||||
|
from ui.alert import AlertDialogBuilder
|
||||||
|
|
||||||
|
count = len(targets)
|
||||||
|
builder = AlertDialogBuilder(fragment.getParentActivity())
|
||||||
|
builder.set_title("Remove deleted message" + ("s" if count > 1 else ""))
|
||||||
|
builder.set_message(
|
||||||
|
("These messages were" if count > 1 else "This message was")
|
||||||
|
+ " already deleted. Remove "
|
||||||
|
+ ("them" if count > 1 else "it")
|
||||||
|
+ " from view on this device?"
|
||||||
|
)
|
||||||
|
|
||||||
|
def remove(_builder, _which):
|
||||||
|
self._remove_from_view(targets)
|
||||||
|
if on_delete is not None:
|
||||||
|
on_delete.run()
|
||||||
|
|
||||||
|
def dismissed(_builder):
|
||||||
|
# The app dims the chat for its own dialog and undims it when
|
||||||
|
# that closes: without this the chat stays touch-blocked.
|
||||||
|
if hide_dim is not None:
|
||||||
|
hide_dim.run()
|
||||||
|
|
||||||
|
builder.set_positive_button("Remove", remove)
|
||||||
|
builder.set_negative_button("Back")
|
||||||
|
builder.make_button_red(AlertDialogBuilder.BUTTON_POSITIVE)
|
||||||
|
builder.set_on_dismiss_listener(dismissed)
|
||||||
|
builder.show()
|
||||||
|
|
||||||
|
def _remove_from_view(self, targets):
|
||||||
|
"""Removes already deleted messages from this device only."""
|
||||||
|
from java import jboolean, jint, jlong
|
||||||
|
from java.lang import Object
|
||||||
|
from java.util import ArrayList
|
||||||
|
from org.telegram.messenger import ChatObject, MessagesController, MessagesStorage
|
||||||
|
from org.telegram.messenger import NotificationCenter
|
||||||
|
|
||||||
|
by_chat = {}
|
||||||
|
for message in targets:
|
||||||
|
account, dialog_id, message_id = _key(message)
|
||||||
|
by_chat.setdefault((account, dialog_id), []).append(message_id)
|
||||||
|
for (account, dialog_id), ids in by_chat.items():
|
||||||
|
store.hide(account, dialog_id, ids)
|
||||||
|
store.forget_deleted(account, dialog_id, ids)
|
||||||
|
channel_id = 0
|
||||||
|
if dialog_id < 0:
|
||||||
|
chat = MessagesController.getInstance(account).getChat(-dialog_id)
|
||||||
|
if chat is not None and ChatObject.isChannel(chat):
|
||||||
|
channel_id = -dialog_id
|
||||||
|
items = ArrayList()
|
||||||
|
for i in ids:
|
||||||
|
items.add(jint(i))
|
||||||
|
MessagesStorage.getInstance(account).markMessagesAsDeleted(
|
||||||
|
jlong(dialog_id), items, jboolean(True), jboolean(False), jint(0), jint(0)
|
||||||
|
)
|
||||||
|
from java import jarray
|
||||||
|
|
||||||
|
NotificationCenter.getInstance(account).postNotificationName(
|
||||||
|
NotificationCenter.messagesDeleted,
|
||||||
|
jarray(Object)([items, jlong(channel_id), jboolean(False)]),
|
||||||
|
)
|
||||||
|
# The notification removes them where the app tracks them; a
|
||||||
|
# kept message may sit in the open chat's list only.
|
||||||
|
self.chats.drop_from_view(account, dialog_id, ids)
|
||||||
|
diag.log(f"removed from view {dialog_id}: {ids}")
|
||||||
|
|
||||||
|
# the deletion itself
|
||||||
|
|
||||||
|
def apply_choice(self, account, dialog_id, ids):
|
||||||
|
"""The dialog's choice for ids being deleted (MessagesController.
|
||||||
|
deleteMessages, whose overloads call one another: the first call
|
||||||
|
takes the choice, later ones see the kept ids). Returns kept ids."""
|
||||||
|
choice, self._choice = self._choice, None
|
||||||
|
if choice is None:
|
||||||
|
return self.keeping(account, ids)
|
||||||
|
if choice[0]:
|
||||||
|
until = time.monotonic() + _KEEP_SECONDS
|
||||||
|
for i in ids:
|
||||||
|
self._keeping[(account, i)] = until
|
||||||
|
# Marked deleted here; the server's echo is then kept like any
|
||||||
|
# deletion by someone else (hooks/deletes.py).
|
||||||
|
store.mark_deleted(account, dialog_id, ids)
|
||||||
|
diag.log(f"keeping own deletion {dialog_id}: {ids}")
|
||||||
|
return list(ids)
|
||||||
|
store.hide(account, dialog_id, ids)
|
||||||
|
return []
|
||||||
|
|
||||||
|
def filter_storage(self, param):
|
||||||
|
"""markMessagesAsDeleted(dialogId, ArrayList ids, ...): drop kept ids."""
|
||||||
|
args = param.args
|
||||||
|
if len(args) < 2 or not hasattr(args[1], "size"):
|
||||||
|
return
|
||||||
|
account = int(field(param.thisObject, "currentAccount") or 0)
|
||||||
|
kept = set(self.keeping(account, java_ints(args[1])))
|
||||||
|
if not kept:
|
||||||
|
return
|
||||||
|
items = args[1]
|
||||||
|
for index in range(items.size() - 1, -1, -1):
|
||||||
|
if int(items.get(index)) in kept:
|
||||||
|
items.remove(jint_index(index))
|
||||||
|
|
||||||
|
def filter_notification(self, param):
|
||||||
|
"""messagesDeleted with kept ids: those are dropped from it."""
|
||||||
|
from org.telegram.messenger import NotificationCenter
|
||||||
|
|
||||||
|
if int(param.args[0]) != int(NotificationCenter.messagesDeleted):
|
||||||
|
return
|
||||||
|
extra = param.args[1]
|
||||||
|
if extra is None or len(extra) < 1 or not hasattr(extra[0], "size"):
|
||||||
|
return
|
||||||
|
account = int(field(param.thisObject, "currentAccount") or 0)
|
||||||
|
kept = set(self.keeping(account, java_ints(extra[0])))
|
||||||
|
if not kept:
|
||||||
|
return
|
||||||
|
items = extra[0]
|
||||||
|
for index in range(items.size() - 1, -1, -1):
|
||||||
|
if int(items.get(index)) in kept:
|
||||||
|
items.remove(jint_index(index))
|
||||||
|
if items.size() == 0:
|
||||||
|
param.setResult(None)
|
||||||
|
|
||||||
|
|
||||||
|
def jint_index(index):
|
||||||
|
"""ArrayList.remove(int index), not remove(Object)."""
|
||||||
|
from java import jint
|
||||||
|
|
||||||
|
return jint(index)
|
||||||
|
|
||||||
|
|
||||||
|
def _message_column(view):
|
||||||
|
"""The dialog's vertical layout holding its message text (and checkboxes)."""
|
||||||
|
from android.widget import LinearLayout, TextView
|
||||||
|
|
||||||
|
found = []
|
||||||
|
|
||||||
|
def walk(node):
|
||||||
|
if found:
|
||||||
|
return
|
||||||
|
if isinstance(node, TextView):
|
||||||
|
parent = node.getParent()
|
||||||
|
if isinstance(parent, LinearLayout) and parent.getOrientation() == LinearLayout.VERTICAL:
|
||||||
|
found.append(parent)
|
||||||
|
return
|
||||||
|
if hasattr(node, "getChildCount"):
|
||||||
|
for index in range(node.getChildCount()):
|
||||||
|
walk(node.getChildAt(index))
|
||||||
|
|
||||||
|
walk(view)
|
||||||
|
return found[0] if found else None
|
||||||
@@ -23,10 +23,11 @@ UPDATES = ("TL_updateDeleteMessages", "TL_updateDeleteChannelMessages")
|
|||||||
|
|
||||||
|
|
||||||
class Deletes:
|
class Deletes:
|
||||||
def __init__(self, plugin, api, chats):
|
def __init__(self, plugin, api, chats, dialog=None):
|
||||||
self.plugin = plugin
|
self.plugin = plugin
|
||||||
self.api = api
|
self.api = api
|
||||||
self.chats = chats
|
self.chats = chats
|
||||||
|
self.dialog = dialog # hooks/delete_dialog.DeleteDialog
|
||||||
self.handles = []
|
self.handles = []
|
||||||
|
|
||||||
def install(self):
|
def install(self):
|
||||||
@@ -70,6 +71,12 @@ class Deletes:
|
|||||||
return
|
return
|
||||||
account = int(field(param.thisObject, "currentAccount") or 0)
|
account = int(field(param.thisObject, "currentAccount") or 0)
|
||||||
dialog_id = int(args[3] or 0)
|
dialog_id = int(args[3] or 0)
|
||||||
|
kept = self.dialog.apply_choice(account, dialog_id, ids) if self.dialog else []
|
||||||
|
if kept:
|
||||||
|
self.chats.refresh_dialog(account, dialog_id, kept)
|
||||||
|
ids = [i for i in ids if i not in kept]
|
||||||
|
if not ids:
|
||||||
|
return
|
||||||
store.note_own_deletion(account, ids)
|
store.note_own_deletion(account, ids)
|
||||||
store.forget_deleted(account, dialog_id, ids)
|
store.forget_deleted(account, dialog_id, ids)
|
||||||
|
|
||||||
|
|||||||
+62
-6
@@ -1,4 +1,4 @@
|
|||||||
from base_plugin import BasePlugin, HookResult
|
from base_plugin import BasePlugin, HookResult, MenuItemData, MenuItemType
|
||||||
from ui.settings import Header, Input, Selector, Switch, Text
|
from ui.settings import Header, Input, Selector, Switch, Text
|
||||||
|
|
||||||
import accounts
|
import accounts
|
||||||
@@ -18,6 +18,7 @@ from config import (
|
|||||||
)
|
)
|
||||||
from hooks.chat import EDIT_UPDATES, ChatSync
|
from hooks.chat import EDIT_UPDATES, ChatSync
|
||||||
from hooks.comments import CommentsButton
|
from hooks.comments import CommentsButton
|
||||||
|
from hooks.delete_dialog import DeleteDialog
|
||||||
from hooks.deletes import UPDATES, Deletes
|
from hooks.deletes import UPDATES, Deletes
|
||||||
from hooks.links import Links
|
from hooks.links import Links
|
||||||
from hooks.menu import Menu
|
from hooks.menu import Menu
|
||||||
@@ -42,20 +43,60 @@ class MessageLoggerPlugin(BasePlugin):
|
|||||||
self.history = History(self, self.api)
|
self.history = History(self, self.api)
|
||||||
self.render = Render(self)
|
self.render = Render(self)
|
||||||
self.chats = ChatSync(self, self.api)
|
self.chats = ChatSync(self, self.api)
|
||||||
self.deletes = Deletes(self, self.api, self.chats)
|
self.delete_dialog = DeleteDialog(self, self.chats)
|
||||||
|
self.deletes = Deletes(self, self.api, self.chats, self.delete_dialog)
|
||||||
self.links = Links(self)
|
self.links = Links(self)
|
||||||
self.menu = Menu(self, self.history.open)
|
self.menu = Menu(self, self.history.open)
|
||||||
self.comments = CommentsButton(self, Comments(self, self.api).open)
|
self.comments = CommentsButton(self, Comments(self, self.api).open)
|
||||||
parts = (self.render, self.chats, self.deletes, self.links, self.menu, self.comments)
|
parts = (
|
||||||
|
self.render,
|
||||||
|
self.chats,
|
||||||
|
self.deletes,
|
||||||
|
self.delete_dialog,
|
||||||
|
self.links,
|
||||||
|
self.menu,
|
||||||
|
self.comments,
|
||||||
|
)
|
||||||
for part in parts:
|
for part in parts:
|
||||||
try:
|
try:
|
||||||
part.install()
|
part.install()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
diag.error(f"install {type(part).__name__}", exc)
|
diag.error(f"install {type(part).__name__}", exc)
|
||||||
|
self.add_menu_item(
|
||||||
|
MenuItemData(
|
||||||
|
menu_type=MenuItemType.CHAT_ACTION_MENU,
|
||||||
|
text="Reset deleted/edited marks",
|
||||||
|
icon="msg_clear",
|
||||||
|
on_click=self._reset_chat,
|
||||||
|
)
|
||||||
|
)
|
||||||
in_background(accounts.refresh)
|
in_background(accounts.refresh)
|
||||||
in_background(self.purge_cache)
|
in_background(self.purge_cache)
|
||||||
diag.log("loaded")
|
diag.log("loaded")
|
||||||
|
|
||||||
|
def _reset_chat(self, context):
|
||||||
|
"""Chat menu: forget this chat's cached marks and check it again."""
|
||||||
|
try:
|
||||||
|
fragment = context.get("fragment")
|
||||||
|
if fragment is None:
|
||||||
|
from client_utils import get_last_fragment
|
||||||
|
|
||||||
|
fragment = get_last_fragment()
|
||||||
|
account = int(fragment.getCurrentAccount())
|
||||||
|
dialog_id = int(context.get("dialog_id") or fragment.getDialogId())
|
||||||
|
removed = self.chats.reset(account, dialog_id)
|
||||||
|
_bulletin(f"Message Logger: {removed} marks reset, checking again")
|
||||||
|
except Exception as exc:
|
||||||
|
diag.error("reset chat", exc)
|
||||||
|
|
||||||
|
def _reset_all(self, _view=None):
|
||||||
|
"""Settings: forget every chat's cached marks."""
|
||||||
|
def run():
|
||||||
|
removed = self.chats.reset()
|
||||||
|
_bulletin(f"Message Logger: {removed} marks reset in all chats")
|
||||||
|
|
||||||
|
in_background(run)
|
||||||
|
|
||||||
def purge_cache(self, force=False):
|
def purge_cache(self, force=False):
|
||||||
"""Removes cached answers older than the configured lifetime; at most
|
"""Removes cached answers older than the configured lifetime; at most
|
||||||
once a day (the day turns at local midnight) unless `force`."""
|
once a day (the day turns at local midnight) unless `force`."""
|
||||||
@@ -66,7 +107,15 @@ class MessageLoggerPlugin(BasePlugin):
|
|||||||
store.purge_old(CACHE_DAYS_CHOICES[index][1], force=force)
|
store.purge_old(CACHE_DAYS_CHOICES[index][1], force=force)
|
||||||
|
|
||||||
def on_plugin_unload(self):
|
def on_plugin_unload(self):
|
||||||
for part in ("render", "chats", "deletes", "links", "menu", "comments"):
|
for part in (
|
||||||
|
"render",
|
||||||
|
"chats",
|
||||||
|
"deletes",
|
||||||
|
"delete_dialog",
|
||||||
|
"links",
|
||||||
|
"menu",
|
||||||
|
"comments",
|
||||||
|
):
|
||||||
if hasattr(self, part):
|
if hasattr(self, part):
|
||||||
getattr(self, part).uninstall()
|
getattr(self, part).uninstall()
|
||||||
diag.log("unloaded")
|
diag.log("unloaded")
|
||||||
@@ -141,7 +190,14 @@ class MessageLoggerPlugin(BasePlugin):
|
|||||||
text="Report deletions",
|
text="Report deletions",
|
||||||
default=DEFAULTS[REPORT_DELETIONS],
|
default=DEFAULTS[REPORT_DELETIONS],
|
||||||
subtext="Tell the server about deletions this client sees",
|
subtext="Tell the server about deletions this client sees",
|
||||||
icon="msg_sync",
|
icon="msg_report",
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
text="Reset deleted/edited marks",
|
||||||
|
subtext="Forget all cached marks; chats are checked with the server again",
|
||||||
|
icon="msg_clear",
|
||||||
|
red=True,
|
||||||
|
on_click=self._reset_all,
|
||||||
),
|
),
|
||||||
Selector(
|
Selector(
|
||||||
key=CACHE_DAYS,
|
key=CACHE_DAYS,
|
||||||
@@ -158,6 +214,6 @@ class MessageLoggerPlugin(BasePlugin):
|
|||||||
text="Deleted message opacity",
|
text="Deleted message opacity",
|
||||||
default=DEFAULTS[DELETED_ALPHA],
|
default=DEFAULTS[DELETED_ALPHA],
|
||||||
items=[label for label, _ in ALPHA_CHOICES],
|
items=[label for label, _ in ALPHA_CHOICES],
|
||||||
icon="msg_hide",
|
icon="msg_palette",
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
@@ -91,6 +91,11 @@ def _conn():
|
|||||||
"at REAL, PRIMARY KEY (account, dialog_id, message_id))"
|
"at REAL, PRIMARY KEY (account, dialog_id, message_id))"
|
||||||
)
|
)
|
||||||
_db.execute("CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT)")
|
_db.execute("CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT)")
|
||||||
|
_db.execute(
|
||||||
|
"CREATE TABLE IF NOT EXISTS hidden ("
|
||||||
|
"account INTEGER, dialog_id INTEGER, message_id INTEGER, at REAL, "
|
||||||
|
"PRIMARY KEY (account, dialog_id, message_id))"
|
||||||
|
)
|
||||||
_db.execute(
|
_db.execute(
|
||||||
"CREATE TABLE IF NOT EXISTS coverage ("
|
"CREATE TABLE IF NOT EXISTS coverage ("
|
||||||
"account INTEGER, dialog_id INTEGER, low INTEGER, high INTEGER, at REAL, "
|
"account INTEGER, dialog_id INTEGER, low INTEGER, high INTEGER, at REAL, "
|
||||||
@@ -180,10 +185,12 @@ def cached_deleted(account, dialog_id, low, high):
|
|||||||
[low, high] (what a restore needs, without asking the server again)."""
|
[low, high] (what a restore needs, without asking the server again)."""
|
||||||
with _lock:
|
with _lock:
|
||||||
_local_ids(account, dialog_id)
|
_local_ids(account, dialog_id)
|
||||||
|
hidden = _hidden_ids(account, dialog_id)
|
||||||
return [
|
return [
|
||||||
state.raw
|
state.raw
|
||||||
for message_id, state in _states.get((account, dialog_id), {}).items()
|
for message_id, state in _states.get((account, dialog_id), {}).items()
|
||||||
if low <= message_id <= high
|
if low <= message_id <= high
|
||||||
|
and message_id not in hidden
|
||||||
and state.raw is not None
|
and state.raw is not None
|
||||||
and state.raw.get("deleted_at")
|
and state.raw.get("deleted_at")
|
||||||
and state.raw.get("logged")
|
and state.raw.get("logged")
|
||||||
@@ -284,6 +291,79 @@ def forget_deleted(account, dialog_id, message_ids):
|
|||||||
diag.error("store.forget_deleted", exc)
|
diag.error("store.forget_deleted", exc)
|
||||||
|
|
||||||
|
|
||||||
|
def reset(account=None, dialog_id=None):
|
||||||
|
"""Forgets cached marks, local deletion records and checked ranges of a
|
||||||
|
chat (or of every chat when `dialog_id` is None); the next look asks the
|
||||||
|
server again. Messages the user removed from view stay hidden.
|
||||||
|
Returns the number of marks forgotten."""
|
||||||
|
where, args = "", ()
|
||||||
|
if dialog_id is not None:
|
||||||
|
where, args = " WHERE account=? AND dialog_id=?", (int(account), int(dialog_id))
|
||||||
|
with _lock:
|
||||||
|
removed = 0
|
||||||
|
try:
|
||||||
|
db = _conn()
|
||||||
|
for table in ("states", "deleted", "coverage"):
|
||||||
|
count = db.execute(f"DELETE FROM {table}{where}", args).rowcount
|
||||||
|
if table != "coverage":
|
||||||
|
removed += count
|
||||||
|
db.commit()
|
||||||
|
except Exception as exc:
|
||||||
|
diag.error("store.reset", exc)
|
||||||
|
if dialog_id is None:
|
||||||
|
_states.clear()
|
||||||
|
_local.clear()
|
||||||
|
_coverage.clear()
|
||||||
|
_loaded.clear()
|
||||||
|
else:
|
||||||
|
key = (int(account), int(dialog_id))
|
||||||
|
for cache in (_states, _local, _coverage):
|
||||||
|
cache.pop(key, None)
|
||||||
|
_loaded.discard(key)
|
||||||
|
return removed
|
||||||
|
|
||||||
|
|
||||||
|
_hidden = {} # (account, dialog_id) -> set of ids the user removed from view
|
||||||
|
|
||||||
|
|
||||||
|
def _hidden_ids(account, dialog_id):
|
||||||
|
key = (account, dialog_id)
|
||||||
|
if key not in _hidden:
|
||||||
|
found = set()
|
||||||
|
try:
|
||||||
|
rows = _conn().execute(
|
||||||
|
"SELECT message_id FROM hidden WHERE account=? AND dialog_id=?", key
|
||||||
|
).fetchall()
|
||||||
|
found = {r[0] for r in rows}
|
||||||
|
except Exception as exc:
|
||||||
|
diag.error("store.hidden", exc)
|
||||||
|
_hidden[key] = found
|
||||||
|
return _hidden[key]
|
||||||
|
|
||||||
|
|
||||||
|
def hide(account, dialog_id, message_ids):
|
||||||
|
"""Messages the user deleted (or removed from view): never shown or
|
||||||
|
restored here again."""
|
||||||
|
ids = [int(i) for i in message_ids]
|
||||||
|
with _lock:
|
||||||
|
_hidden_ids(account, dialog_id).update(ids)
|
||||||
|
try:
|
||||||
|
db = _conn()
|
||||||
|
now = time.time()
|
||||||
|
db.executemany(
|
||||||
|
"INSERT OR IGNORE INTO hidden VALUES (?, ?, ?, ?)",
|
||||||
|
[(account, dialog_id, i, now) for i in ids],
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
except Exception as exc:
|
||||||
|
diag.error("store.hide", exc)
|
||||||
|
|
||||||
|
|
||||||
|
def is_hidden(account, dialog_id, message_id):
|
||||||
|
with _lock:
|
||||||
|
return int(message_id) in _hidden_ids(account, dialog_id)
|
||||||
|
|
||||||
|
|
||||||
def note_own_deletion(account, message_ids):
|
def note_own_deletion(account, message_ids):
|
||||||
now = time.monotonic()
|
now = time.monotonic()
|
||||||
with _lock:
|
with _lock:
|
||||||
|
|||||||
@@ -304,7 +304,7 @@ class Comments(History):
|
|||||||
if rich is not None:
|
if rich is not None:
|
||||||
_strip_rich_header(rich)
|
_strip_rich_header(rich)
|
||||||
plain = _rich_plain_text(rich)
|
plain = _rich_plain_text(rich)
|
||||||
if plain is not None: # text only: a normal bubble
|
if plain: # text only: a normal bubble
|
||||||
msg.message = plain
|
msg.message = plain
|
||||||
msg.entities = ArrayList()
|
msg.entities = ArrayList()
|
||||||
msg.rich_message = None
|
msg.rich_message = None
|
||||||
|
|||||||
@@ -311,9 +311,39 @@ def main(argv=None) -> int:
|
|||||||
f"removed={removed} left={left}",
|
f"removed={removed} left={left}",
|
||||||
)
|
)
|
||||||
check("age purge runs once a day", store.purge_old(1) == 0)
|
check("age purge runs once a day", store.purge_old(1) == 0)
|
||||||
|
store.put_server_states(0, -7, [{"message_id": 1, "edit_count": 1}])
|
||||||
|
store.put_server_states(0, -8, [{"message_id": 1, "edit_count": 1}])
|
||||||
|
store.mark_deleted(0, -7, [2])
|
||||||
|
store.hide(0, -7, [3])
|
||||||
|
check(
|
||||||
|
"reset forgets one chat's marks, keeps others and hidden ones",
|
||||||
|
store.reset(0, -7) == 2
|
||||||
|
and store.get(0, -7, 1) is None
|
||||||
|
and store.get(0, -8, 1) is not None
|
||||||
|
and store.is_hidden(0, -7, 3),
|
||||||
|
)
|
||||||
for name in ("cached_deleted", "coverage", "set_coverage", "expire_coverage", "replace_window"):
|
for name in ("cached_deleted", "coverage", "set_coverage", "expire_coverage", "replace_window"):
|
||||||
check(f"store.{name} exists", callable(getattr(store, name, None)))
|
check(f"store.{name} exists", callable(getattr(store, name, None)))
|
||||||
|
|
||||||
|
class JList(list):
|
||||||
|
def size(self):
|
||||||
|
return len(self)
|
||||||
|
|
||||||
|
def get(self, index):
|
||||||
|
return self[index]
|
||||||
|
|
||||||
|
class Node:
|
||||||
|
def __init__(self, text=None, texts=()):
|
||||||
|
self.text, self.texts = text, JList(texts)
|
||||||
|
|
||||||
|
url = Node(Node("https://t.me/c/1/2")) # textUrl: empty `texts`
|
||||||
|
concat = Node(None, [Node("a "), Node(Node("b"))])
|
||||||
|
check(
|
||||||
|
"rich text: wrappers and concatenations",
|
||||||
|
chat._rich_text(url) == "https://t.me/c/1/2" and chat._rich_text(concat) == "a b",
|
||||||
|
repr((chat._rich_text(url), chat._rich_text(concat))),
|
||||||
|
)
|
||||||
|
|
||||||
api = load("api")
|
api = load("api")
|
||||||
client = api.Api(BasePlugin())
|
client = api.Api(BasePlugin())
|
||||||
check("API unconfigured without URL and token", not client.configured)
|
check("API unconfigured without URL and token", not client.configured)
|
||||||
|
|||||||
Reference in new issue
Block a user