diff --git a/doc/changelog.md b/doc/changelog.md index 72875542..92745afa 100644 --- a/doc/changelog.md +++ b/doc/changelog.md @@ -5,6 +5,7 @@ TWBlue Changelog * Core: * Expanded the keystroke editor actions list. Now, many previously hidden or unassignable actions are available to be mapped to custom keyboard shortcuts. * Mastodon: + * Added support for sending quoted posts! You can now quote other users' posts from the context menu or the new Boost dialog. ([#860](https://github.com/mcv-software/twblue/issues/860)) * Fixed an issue where HTML entities were not decoded when editing a post. ([#893](https://github.com/mcv-software/twblue/issues/893)) ## Changes in version 2026.01.13 diff --git a/src/controller/buffers/mastodon/base.py b/src/controller/buffers/mastodon/base.py index 1595f932..91fb03e7 100644 --- a/src/controller/buffers/mastodon/base.py +++ b/src/controller/buffers/mastodon/base.py @@ -311,8 +311,10 @@ class BaseBuffer(base.Buffer): 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) + widgetUtils.connect_event(menu, widgetUtils.MENU, self.quote, menuitem=menu.quote) else: menu.boost.Enable(False) + menu.quote.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.mute_conversation, menuitem=menu.mute) @@ -442,11 +444,30 @@ class BaseBuffer(base.Buffer): id = item.id if self.session.settings["general"]["boost_mode"] == "ask": answer = mastodon_dialogs.boost_question() - if answer == True: + if answer == 1: self._direct_boost(id) + elif answer == 2: + self.quote(item=item) else: self._direct_boost(id) + def quote(self, event=None, 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.")) + + title = _("Quote post") + caption = _("Write your comment here") + post = messages.post(session=self.session, title=title, caption=caption) + + response = post.message.ShowModal() + if response == wx.ID_OK: + post_data = post.get_data() + call_threaded(self.session.send_post, quote_id=item.id, posts=post_data, visibility=post.get_visibility(), language=post.get_language(), **kwargs) + if hasattr(post.message, "destroy"): + post.message.destroy() + def _direct_boost(self, id): item = self.session.api_call(call_name="status_reblog", _sound="retweet_send.ogg", id=id) diff --git a/src/controller/mastodon/handler.py b/src/controller/mastodon/handler.py index 3f5086d7..89e45a04 100644 --- a/src/controller/mastodon/handler.py +++ b/src/controller/mastodon/handler.py @@ -35,6 +35,7 @@ class Handler(object): compose=_("&Post"), reply=_("Re&ply"), share=_("&Boost"), + quote=_("&Quote"), fav=_("&Add to favorites"), unfav=_("Remove from favorites"), view=_("&Show post"), diff --git a/src/sessions/mastodon/session.py b/src/sessions/mastodon/session.py index 658fd3c4..8a205862 100644 --- a/src/sessions/mastodon/session.py +++ b/src/sessions/mastodon/session.py @@ -217,38 +217,69 @@ class Session(base.baseSession): self.sound.play(_sound) return val - def send_post(self, reply_to=None, visibility=None, language=None, posts=[]): + def _send_quote_post(self, text, quote_id, visibility, sensitive, spoiler_text, language, scheduled_at, in_reply_to_id=None, media_ids=[], poll=None): + """Internal helper to send a quote post using direct API call.""" + params = { + 'status': text, + 'visibility': visibility, + 'quoted_status_id': quote_id, + } + if in_reply_to_id: + params['in_reply_to_id'] = in_reply_to_id + if sensitive: + params['sensitive'] = sensitive + if spoiler_text: + params['spoiler_text'] = spoiler_text + if language: + params['language'] = language + if scheduled_at: + if hasattr(scheduled_at, 'isoformat'): + params['scheduled_at'] = scheduled_at.isoformat() + else: + params['scheduled_at'] = scheduled_at + if media_ids: + params['media_ids'] = media_ids + if poll: + params['poll'] = poll + + # Use the internal API request method directly + return self.api._Mastodon__api_request('POST', '/api/v1/statuses', params) + + def send_post(self, reply_to=None, quote_id=None, visibility=None, language=None, posts=[]): """ Convenience function to send a thread. """ in_reply_to_id = reply_to for obj in posts: text = obj.get("text") scheduled_at = obj.get("scheduled_at") - if len(obj["attachments"]) == 0: + + # Prepare media and polls first as they are needed for both standard and quote posts + media_ids = [] + poll = None + if len(obj["attachments"]) > 0: try: - item = self.api_call(call_name="status_post", status=text, _sound="tweet_send.ogg", in_reply_to_id=in_reply_to_id, visibility=visibility, sensitive=obj["sensitive"], spoiler_text=obj["spoiler_text"], language=language, scheduled_at=scheduled_at) - # If it fails, let's basically send an event with all passed info so we will catch it later. - except Exception as e: - pub.sendMessage("mastodon.error_post", name=self.get_name(), reply_to=reply_to, visibility=visibility, posts=posts, lang=language) - return - if item != None: - in_reply_to_id = item["id"] - else: - media_ids = [] - try: - poll = None if len(obj["attachments"]) == 1 and obj["attachments"][0]["type"] == "poll": poll = self.api.make_poll(options=obj["attachments"][0]["options"], expires_in=obj["attachments"][0]["expires_in"], multiple=obj["attachments"][0]["multiple"], hide_totals=obj["attachments"][0]["hide_totals"]) else: for i in obj["attachments"]: media = self.api_call("media_post", media_file=i["file"], description=i["description"], synchronous=True) media_ids.append(media.id) - item = self.api_call(call_name="status_post", status=text, _sound="tweet_send.ogg", in_reply_to_id=in_reply_to_id, media_ids=media_ids, visibility=visibility, poll=poll, sensitive=obj["sensitive"], spoiler_text=obj["spoiler_text"], language=language, scheduled_at=scheduled_at) - if item != None: - in_reply_to_id = item["id"] except Exception as e: - pub.sendMessage("mastodon.error_post", name=self.get_name(), reply_to=reply_to, visibility=visibility, posts=posts, lang=language) + pub.sendMessage("mastodon.error_post", name=self.get_name(), reply_to=reply_to, visibility=visibility, posts=posts, language=language) return + try: + if quote_id: + item = self._send_quote_post(text, quote_id, visibility, obj["sensitive"], obj["spoiler_text"], language, scheduled_at, in_reply_to_id, media_ids, poll) + self.sound.play("tweet_send.ogg") + else: + item = self.api_call(call_name="status_post", status=text, _sound="tweet_send.ogg", in_reply_to_id=in_reply_to_id, media_ids=media_ids, visibility=visibility, poll=poll, sensitive=obj["sensitive"], spoiler_text=obj["spoiler_text"], language=language, scheduled_at=scheduled_at) + + if item != None: + in_reply_to_id = item["id"] + except Exception as e: + pub.sendMessage("mastodon.error_post", name=self.get_name(), reply_to=reply_to, visibility=visibility, posts=posts, language=language) + return + def edit_post(self, post_id, posts=[]): """ Convenience function to edit a post. Only the first item in posts list is used as threads cannot be edited. diff --git a/src/wxUI/dialogs/mastodon/dialogs.py b/src/wxUI/dialogs/mastodon/dialogs.py index 1f945d82..865dd36a 100644 --- a/src/wxUI/dialogs/mastodon/dialogs.py +++ b/src/wxUI/dialogs/mastodon/dialogs.py @@ -2,11 +2,43 @@ import wx import application +class BoostDialog(wx.Dialog): + def __init__(self): + super(BoostDialog, self).__init__(None, title=_("Boost")) + p = wx.Panel(self) + sizer = wx.BoxSizer(wx.VERTICAL) + lbl = wx.StaticText(p, wx.ID_ANY, _("What would you like to do with this post?")) + sizer.Add(lbl, 0, wx.ALL, 10) + + btn_sizer = wx.BoxSizer(wx.HORIZONTAL) + self.btn_boost = wx.Button(p, wx.ID_ANY, _("Boost")) + self.btn_quote = wx.Button(p, wx.ID_ANY, _("Quote")) + self.btn_cancel = wx.Button(p, wx.ID_CANCEL, _("Cancel")) + + btn_sizer.Add(self.btn_boost, 0, wx.ALL, 5) + btn_sizer.Add(self.btn_quote, 0, wx.ALL, 5) + btn_sizer.Add(self.btn_cancel, 0, wx.ALL, 5) + + sizer.Add(btn_sizer, 0, wx.ALIGN_CENTER) + p.SetSizer(sizer) + sizer.Fit(self) + + self.btn_boost.Bind(wx.EVT_BUTTON, self.on_boost) + self.btn_quote.Bind(wx.EVT_BUTTON, self.on_quote) + self.result = 0 + + def on_boost(self, event): + self.result = 1 + self.EndModal(wx.ID_OK) + + def on_quote(self, event): + self.result = 2 + self.EndModal(wx.ID_OK) + def boost_question(): - result = False - dlg = wx.MessageDialog(None, _("Would you like to share this post?"), _("Boost"), wx.YES_NO|wx.ICON_QUESTION) - if dlg.ShowModal() == wx.ID_YES: - result = True + dlg = BoostDialog() + dlg.ShowModal() + result = dlg.result dlg.Destroy() return result diff --git a/src/wxUI/dialogs/mastodon/menus.py b/src/wxUI/dialogs/mastodon/menus.py index 62dd3407..b7277ab4 100644 --- a/src/wxUI/dialogs/mastodon/menus.py +++ b/src/wxUI/dialogs/mastodon/menus.py @@ -6,6 +6,8 @@ class base(wx.Menu): super(base, self).__init__() self.boost = wx.MenuItem(self, wx.ID_ANY, _("&Boost")) self.Append(self.boost) + self.quote = wx.MenuItem(self, wx.ID_ANY, _("&Quote")) + self.Append(self.quote) self.reply = wx.MenuItem(self, wx.ID_ANY, _(u"Re&ply")) self.Append(self.reply) self.edit = wx.MenuItem(self, wx.ID_ANY, _(u"&Edit")) @@ -38,6 +40,8 @@ class notification(wx.Menu): if item in valid_types: self.boost = wx.MenuItem(self, wx.ID_ANY, _("&Boost")) self.Append(self.boost) + self.quote = wx.MenuItem(self, wx.ID_ANY, _("&Quote")) + self.Append(self.quote) self.reply = wx.MenuItem(self, wx.ID_ANY, _(u"Re&ply")) self.Append(self.reply) self.edit = wx.MenuItem(self, wx.ID_ANY, _(u"&Edit"))