From cdcbcf754a25c8abd37b9e3f7b50c152bf234b20 Mon Sep 17 00:00:00 2001 From: Manuel Cortez Date: Fri, 5 Jan 2024 15:49:18 -0600 Subject: [PATCH] Mastodon: Added actions for notifications. closes #517 --- doc/changelog.md | 6 +- src/controller/buffers/mastodon/base.py | 120 ++++++++++-------- .../buffers/mastodon/notifications.py | 120 +++++++++++++++++- src/controller/mastodon/handler.py | 13 ++ src/wxUI/dialogs/mastodon/menus.py | 30 ++++- 5 files changed, 227 insertions(+), 62 deletions(-) diff --git a/doc/changelog.md b/doc/changelog.md index 21e35556f..44faf935b 100644 --- a/doc/changelog.md +++ b/doc/changelog.md @@ -4,17 +4,19 @@ TWBlue Changelog * Core: * The TWBlue website will no longer be available on the twblue.es domain. Beginning in January 2024, TWBlue will live at https://twblue.mcvsoftware.com. Also, we will start releasing versions on [gitHub releases](https://github.com/mcv-software/twblue/releases) so it will be easier to track specific versions. - * As of the first release of TWBlue in 2024, we will officially stop generating 32-bit (X86) compatible binaries due to the increasing difficulty of generating versions compatible with this architecture in modern Python versions. + * As of the first release of TWBlue in 2024, we will officially stop generating 32-bit (X86) compatible binaries due to the increasing difficulty of generating versions compatible with this architecture in modern Python. * TWBlue should be more reliable when checking for updates. * If running from source, automatic updates will not be checked as this works only for distribution. ([#540](https://github.com/MCV-Software/TWBlue/pull/540)) * Fixed the 'report an error' item in the help menu. Now this item redirects to our gitHub issue tracker. ([#524](https://github.com/MCV-Software/TWBlue/pull/524)) * Mastodon: + * Implemented actions for the notifications buffer on a mastodon instance. Actions can be performed from the contextual menu on every notification, or by using invisible keystrokes. ([#517](https://github.com/mcv-software/twblue(issues/517)) * Implemented update profile dialog. ([#547](https://github.com/MCV-Software/TWBlue/pull/547)) * It is possible to display an user profile from the user menu within the menu bar, or by using the invisible keystroke for user details. ([#555](https://github.com/MCV-Software/TWBlue/pull/555)) - * Added possibility to vote in polls. + * Added possibility to vote in polls. This is mapped to Alt+Win+Shift+V in the invisible keymaps for windows 10/11. * Added posts search. Take into account that Mastodon instances should be configured with full text search enabled. Search for posts only include posts the logged-in user has interacted with. ([#541](https://github.com/MCV-Software/TWBlue/pull/541)) * Added user autocompletion settings in account settings dialog, so it is possible to ask TWBlue to scan mastodon accounts and add people from followers and following buffers. For now, user autocompletion can be used only when composing new posts or replies. * TWBlue should be able to ignore deleted direct messages or messages from deleted accounts. Previously, a direct message that no longer existed in the instance caused errors when loading the direct messages buffer and could potentially affect notifications as well. + * TWBlue should be able to ignore notifications from deleted accounts or posts. ## changes in version 2023.4.13 diff --git a/src/controller/buffers/mastodon/base.py b/src/controller/buffers/mastodon/base.py index 3e747596a..0cb63e3af 100644 --- a/src/controller/buffers/mastodon/base.py +++ b/src/controller/buffers/mastodon/base.py @@ -263,7 +263,10 @@ def show_menu(self, ev, pos=0, *args, **kwargs): menu = menus.base() widgetUtils.connect_event(menu, widgetUtils.MENU, self.reply, menuitem=menu.reply) widgetUtils.connect_event(menu, widgetUtils.MENU, self.user_actions, menuitem=menu.userActions) - widgetUtils.connect_event(menu, widgetUtils.MENU, self.share_item, menuitem=menu.boost) + if self.can_share() == True: + widgetUtils.connect_event(menu, widgetUtils.MENU, self.share_item, menuitem=menu.boost) + else: + menu.boost.Enable(False) widgetUtils.connect_event(menu, widgetUtils.MENU, self.fav, menuitem=menu.fav) widgetUtils.connect_event(menu, widgetUtils.MENU, self.unfav, menuitem=menu.unfav) widgetUtils.connect_event(menu, widgetUtils.MENU, self.url_, menuitem=menu.openUrl) @@ -310,14 +313,16 @@ def get_item(self): if index > -1 and self.session.db.get(self.name) != None: return self.session.db[self.name][index] - def can_share(self): - post = self.get_item() - if post.visibility == "direct": + def can_share(self, item=None): + if item == None: + item = self.get_item() + if item.visibility == "direct": return False return True - def reply(self, *args): - item = self.get_item() + def reply(self, item=None, *args, **kwargs): + if item == None: + item = self.get_item() visibility = item.visibility if visibility == "direct": title = _("Conversation with {}").format(item.account.username) @@ -352,8 +357,9 @@ def reply(self, *args): if hasattr(post.message, "destroy"): post.message.destroy() - def send_message(self, *args, **kwargs): - item = self.get_item() + def send_message(self, item=None, *args, **kwargs): + if item == None: + item = self.get_item() title = _("Conversation with {}").format(item.account.username) caption = _("Write your message here") if item.reblog != None: @@ -378,11 +384,12 @@ def send_message(self, *args, **kwargs): if hasattr(post.message, "destroy"): post.message.destroy() - def share_item(self, *args, **kwargs): - if self.can_share() == False: + def share_item(self, item=None, *args, **kwargs): + if item == None: + item = self.get_item() + if self.can_share(item=item) == False: return output.speak(_("This action is not supported on conversations.")) - post = self.get_item() - id = post.id + id = item.id if self.session.settings["general"]["boost_mode"] == "ask": answer = mastodon_dialogs.boost_question() if answer == True: @@ -407,12 +414,11 @@ def onFocus(self, *args, **kwargs): pub.sendMessage("toggleShare", shareable=can_share) self.buffer.boost.Enable(can_share) - def audio(self, url='', *args, **kwargs): + def audio(self, item=None, *args, **kwargs): if sound.URLPlayer.player.is_playing(): return sound.URLPlayer.stop_audio() - item = self.get_item() if item == None: - return + item = self.get_item() urls = utils.get_media_urls(item) if len(urls) == 1: url=urls[0] @@ -428,25 +434,25 @@ def audio(self, url='', *args, **kwargs): # except: # log.error("Exception while executing audio method.") - def url(self, url='', announce=True, *args, **kwargs): - if url == '': - post = self.get_item() - if post.reblog != None: - urls = utils.find_urls(post.reblog) - else: - urls = utils.find_urls(post) - if len(urls) == 1: - url=urls[0] - elif len(urls) > 1: - urls_list = urlList.urlList() - urls_list.populate_list(urls) - if urls_list.get_response() == widgetUtils.OK: - url=urls_list.get_string() - if hasattr(urls_list, "destroy"): urls_list.destroy() - if url != '': - if announce: - output.speak(_(u"Opening URL..."), True) - webbrowser.open_new_tab(url) + def url(self, announce=True, item=None, *args, **kwargs): + if item == None: + item = self.get_item() + if item.reblog != None: + urls = utils.find_urls(item.reblog) + else: + urls = utils.find_urls(item) + if len(urls) == 1: + url=urls[0] + elif len(urls) > 1: + urls_list = urlList.urlList() + urls_list.populate_list(urls) + if urls_list.get_response() == widgetUtils.OK: + url=urls_list.get_string() + if hasattr(urls_list, "destroy"): urls_list.destroy() + if url != '': + if announce: + output.speak(_(u"Opening URL..."), True) + webbrowser.open_new_tab(url) def clear_list(self): dlg = commonMessageDialogs.clear_list() @@ -476,31 +482,37 @@ def user_details(self): item = self.get_item() pass - def get_item_url(self): - post = self.get_item() - if post.reblog != None: - return post.reblog.url - return post.url + def get_item_url(self, item=None): + if item == None: + item = self.get_item() + if item.reblog != None: + return item.reblog.url + return item.url - def open_in_browser(self, *args, **kwargs): - url = self.get_item_url() + def open_in_browser(self, item=None, *args, **kwargs): + if item == None: + item = self.get_item() + url = self.get_item_url(item=item) output.speak(_("Opening item in web browser...")) webbrowser.open(url) - def add_to_favorites(self): - item = self.get_item() + def add_to_favorites(self, item=None): + if item == None: + item = self.get_item() if item.reblog != None: item = item.reblog call_threaded(self.session.api_call, call_name="status_favourite", preexec_message=_("Adding to favorites..."), _sound="favourite.ogg", id=item.id) - def remove_from_favorites(self): - item = self.get_item() + def remove_from_favorites(self, item=None): + if item == None: + item = self.get_item() if item.reblog != None: item = item.reblog call_threaded(self.session.api_call, call_name="status_unfavourite", preexec_message=_("Removing from favorites..."), _sound="favourite.ogg", id=item.id) - def toggle_favorite(self, *args, **kwargs): - item = self.get_item() + def toggle_favorite(self, item=None, *args, **kwargs): + if item == None: + item = self.get_item() if item.reblog != None: item = item.reblog try: @@ -513,8 +525,9 @@ def toggle_favorite(self, *args, **kwargs): else: call_threaded(self.session.api_call, call_name="status_unfavourite", preexec_message=_("Removing from favorites..."), _sound="favourite.ogg", id=item.id) - def toggle_bookmark(self, *args, **kwargs): - item = self.get_item() + def toggle_bookmark(self, item=None, *args, **kwargs): + if item == None: + item = self.get_item() if item.reblog != None: item = item.reblog try: @@ -527,16 +540,17 @@ def toggle_bookmark(self, *args, **kwargs): else: call_threaded(self.session.api_call, call_name="status_unbookmark", preexec_message=_("Removing from bookmarks..."), _sound="favourite.ogg", id=item.id) - def view_item(self): - post = self.get_item() + def view_item(self, item=None): + if item == None: + item = self.get_item() # Update object so we can retrieve newer stats try: - post = self.session.api.status(id=post.id) + item = self.session.api.status(id=item.id) except MastodonNotFoundError: output.speak(_("No status found with that ID")) return # print(post) - msg = messages.viewPost(post, offset_hours=self.session.db["utc_offset"], item_url=self.get_item_url()) + msg = messages.viewPost(item, offset_hours=self.session.db["utc_offset"], item_url=self.get_item_url(item=item)) def ocr_image(self): post = self.get_item() diff --git a/src/controller/buffers/mastodon/notifications.py b/src/controller/buffers/mastodon/notifications.py index 77b0fa575..484cdee5f 100644 --- a/src/controller/buffers/mastodon/notifications.py +++ b/src/controller/buffers/mastodon/notifications.py @@ -3,15 +3,23 @@ import logging import widgetUtils import output +from pubsub import pub from controller.buffers.mastodon.base import BaseBuffer +from controller.mastodon import messages from sessions.mastodon import compose, templates from wxUI import buffers from wxUI.dialogs.mastodon import dialogs as mastodon_dialogs +from wxUI.dialogs.mastodon import menus +from mysc.thread_utils import call_threaded log = logging.getLogger("controller.buffers.mastodon.notifications") class NotificationsBuffer(BaseBuffer): + def __init__(self, *args, **kwargs): + super(NotificationsBuffer, self).__init__(*args, **kwargs) + self.type = "notificationsBuffer" + def get_message(self): notification = self.get_item() if notification == None: @@ -36,16 +44,90 @@ def bind_events(self): widgetUtils.connect_event(self.buffer, widgetUtils.BUTTON_PRESSED, self.post_status, self.buffer.post) widgetUtils.connect_event(self.buffer, widgetUtils.BUTTON_PRESSED, self.destroy_status, self.buffer.dismiss) - def fav(self): + def vote(self): pass - def unfav(self): - pass + def can_share(self, *args, **kwargs): + if self.is_post(): + item = self.get_item() + return super(NotificationsBuffer, self).can_share(item=item.status) + return False - def vote(self): - pass + def add_to_favorites(self): + if self.is_post(): + item = self.get_item() + super(NotificationsBuffer, self).add_to_favorites(item=item.status) + + def remove_from_favorites(self): + if self.is_post(): + item = self.get_item() + super(NotificationsBuffer, self).remove_from_favorites(item=item.status) + + def toggle_favorite(self, *args, **kwargs): + if self.is_post(): + item = self.get_item() + super(NotificationsBuffer, self).toggle_favorite(item=item.status) + + def toggle_bookmark(self, *args, **kwargs): + if self.is_post(): + item = self.get_item() + super(NotificationsBuffer, self).toggle_bookmark(item=item.status) + + def reply(self, *args, **kwargs): + if self.is_post(): + item = self.get_item() + super(NotificationsBuffer, self).reply(item=item.status) - def can_share(self): + def share_item(self, *args, **kwargs): + if self.is_post(): + item = self.get_item() + super(NotificationsBuffer, self).share_item(item=item.status) + + def url(self, *args, **kwargs): + if self.is_post(): + item = self.get_item() + super(NotificationsBuffer, self).url(item=item.status, *args, **kwargs) + + def audio(self, *args, **kwargs): + if self.is_post(): + item = self.get_item() + super(NotificationsBuffer, self).audio(item=item.status) + + def view_item(self, *args, **kwargs): + if self.is_post(): + item = self.get_item() + super(NotificationsBuffer, self).view_item(item=item.status) + else: + pub.sendMessage("execute-action", action="user_details") + + def open_in_browser(self, *args, **kwargs): + if self.is_post(): + item = self.get_item() + super(NotificationsBuffer, self).open_in_browser(item=item.status) + + def send_message(self, *args, **kwargs): + if self.is_post(): + item = self.get_item() + super(NotificationsBuffer, self).send_message(item=item.status) + else: + item = self.get_item() + title = _("New conversation with {}").format(item.account.username) + caption = _("Write your message here") + users_str = "@{} ".format(item.account.acct) + post = messages.post(session=self.session, title=title, caption=caption, text=users_str) + post.message.visibility.SetSelection(3) + response = post.message.ShowModal() + if response == wx.ID_OK: + post_data = post.get_data() + call_threaded(self.session.send_post, posts=post_data, visibility="direct") + if hasattr(post.message, "destroy"): + post.message.destroy() + + def is_post(self): + post_types = ["status", "mention", "reblog", "favourite", "update", "poll"] + item = self.get_item() + if item.type in post_types: + return True return False def destroy_status(self, *args, **kwargs): @@ -64,3 +146,29 @@ def destroy_status(self, *args, **kwargs): self.session.sound.play("error.ogg") log.exception("") self.session.db[self.name] = items + + def show_menu(self, ev, pos=0, *args, **kwargs): + if self.buffer.list.get_count() == 0: + return + notification = self.get_item() + menu = menus.notification(notification.type) + if self.is_post(): + widgetUtils.connect_event(menu, widgetUtils.MENU, self.reply, menuitem=menu.reply) + widgetUtils.connect_event(menu, widgetUtils.MENU, self.user_actions, menuitem=menu.userActions) + if self.can_share() == True: + widgetUtils.connect_event(menu, widgetUtils.MENU, self.share_item, menuitem=menu.boost) + else: + menu.boost.Enable(False) + widgetUtils.connect_event(menu, widgetUtils.MENU, self.fav, menuitem=menu.fav) + widgetUtils.connect_event(menu, widgetUtils.MENU, self.unfav, menuitem=menu.unfav) + widgetUtils.connect_event(menu, widgetUtils.MENU, self.url_, menuitem=menu.openUrl) + widgetUtils.connect_event(menu, widgetUtils.MENU, self.audio, menuitem=menu.play) + widgetUtils.connect_event(menu, widgetUtils.MENU, self.view, menuitem=menu.view) + widgetUtils.connect_event(menu, widgetUtils.MENU, self.copy, menuitem=menu.copy) + widgetUtils.connect_event(menu, widgetUtils.MENU, self.destroy_status, menuitem=menu.remove) + if hasattr(menu, "openInBrowser"): + widgetUtils.connect_event(menu, widgetUtils.MENU, self.open_in_browser, menuitem=menu.openInBrowser) + if pos != 0: + self.buffer.PopupMenu(menu, pos) + else: + self.buffer.PopupMenu(menu, self.buffer.list.list.GetPosition()) \ No newline at end of file diff --git a/src/controller/mastodon/handler.py b/src/controller/mastodon/handler.py index 9b2e2116f..8ca3f6140 100644 --- a/src/controller/mastodon/handler.py +++ b/src/controller/mastodon/handler.py @@ -142,6 +142,17 @@ def follow(self, buffer): users = [user.acct for user in item.mentions if user.id != buffer.session.db["user_id"]] if item.account.acct not in users: users.insert(0, item.account.acct) + elif buffer.type == "notificationsBuffer": + if buffer.is_post(): + status = item.status + if status.reblog != None: + users = [user.acct for user in status.reblog.mentions if user.id != buffer.session.db["user_id"]] + if status.reblog.account.acct not in users and status.account.id != buffer.session.db["user_id"]: + users.insert(0, status.reblog.account.acct) + else: + users = [user.acct for user in status.mentions if user.id != buffer.session.db["user_id"]] + if item.account.acct not in users: + users.insert(0, item.account.acct) u = userActions.userActions(buffer.session, users) def search(self, controller, session, value): @@ -316,6 +327,8 @@ def user_details(self, buffer): log.debug(f"Opening user profile. dictionary: {item}") mentionedUsers = list() holdUser = item.account if item.get('account') else None + if hasattr(item, "type") and item.type in ["status", "mention", "reblog", "favourite", "update", "poll"]: # statuses in Notification buffers + item = item.status if item.get('username'): # account dict holdUser = item elif isinstance(item.get('mentions'), list): diff --git a/src/wxUI/dialogs/mastodon/menus.py b/src/wxUI/dialogs/mastodon/menus.py index 8e832ebf5..63a9cb56e 100644 --- a/src/wxUI/dialogs/mastodon/menus.py +++ b/src/wxUI/dialogs/mastodon/menus.py @@ -25,4 +25,32 @@ def __init__(self): self.remove = wx.MenuItem(self, wx.ID_ANY, _(u"&Delete")) self.Append(self.remove) self.userActions = wx.MenuItem(self, wx.ID_ANY, _(u"&User actions...")) - self.Append(self.userActions) \ No newline at end of file + self.Append(self.userActions) + +class notification(wx.Menu): + def __init__(self, item="status"): + super(notification, self).__init__() + valid_types = ["status", "mention", "reblog", "favourite", "update", "poll"] + if item in valid_types: + self.boost = wx.MenuItem(self, wx.ID_ANY, _("&Boost")) + self.Append(self.boost) + self.reply = wx.MenuItem(self, wx.ID_ANY, _(u"Re&ply")) + self.Append(self.reply) + self.fav = wx.MenuItem(self, wx.ID_ANY, _(u"&Add to favorites")) + self.Append(self.fav) + self.unfav = wx.MenuItem(self, wx.ID_ANY, _(u"R&emove from favorites")) + self.Append(self.unfav) + self.openUrl = wx.MenuItem(self, wx.ID_ANY, _("&Open URL")) + self.Append(self.openUrl) + self.play = wx.MenuItem(self, wx.ID_ANY, _(u"&Play audio")) + self.Append(self.play) + self.openInBrowser = wx.MenuItem(self, wx.ID_ANY, _(u"&Open in instance")) + self.Append(self.openInBrowser) + self.view = wx.MenuItem(self, wx.ID_ANY, _(u"&Show post")) + self.Append(self.view) + self.copy = wx.MenuItem(self, wx.ID_ANY, _(u"&Copy to clipboard")) + self.Append(self.copy) + self.remove = wx.MenuItem(self, wx.ID_ANY, _(u"&Dismiss")) + self.Append(self.remove) + self.userActions = wx.MenuItem(self, wx.ID_ANY, _(u"&User actions...")) + self.Append(self.userActions)