Compare commits

...

9 Commits

Author SHA1 Message Date
dependabot[bot]
b8eede93c1 build(deps): bump setuptools from 80.9.0 to 80.10.2
Bumps [setuptools](https://github.com/pypa/setuptools) from 80.9.0 to 80.10.2.
- [Release notes](https://github.com/pypa/setuptools/releases)
- [Changelog](https://github.com/pypa/setuptools/blob/main/NEWS.rst)
- [Commits](https://github.com/pypa/setuptools/compare/v80.9.0...v80.10.2)

---
updated-dependencies:
- dependency-name: setuptools
  dependency-version: 80.10.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-01-26 15:12:50 +00:00
beb676d9ab mastodon: fix: ensure pagination works correctly with pinned posts
Some checks failed
Update translation files / update_catalogs (push) Failing after 3s
2026-01-21 12:00:50 -06:00
e5822ac8ee mastodon: feat: implement support for sending quoted posts 2026-01-21 10:57:00 -06:00
320c7a361c Fix HTML entity decoding when editing Mastodon posts (#893) 2026-01-21 08:44:13 -06:00
7c131b6936 keystroke editor: expand available actions and update keymaps
Some checks failed
Update translation files / update_catalogs (push) Failing after 29s
2026-01-16 13:59:02 -06:00
198b1eefb7 Merge branch 'next-gen' of github.com:mcv-software/twblue into next-gen 2026-01-13 00:44:46 -06:00
363d2082c0 release a new version in our updates system 2026-01-13 00:43:46 -06:00
José Manuel Delicado
d65109d935 Merge pull request #891 from MCV-Software/dependabot/pip/numpy-2.4.1
build(deps): bump numpy from 2.4.0 to 2.4.1
2026-01-13 07:29:51 +01:00
dependabot[bot]
0512d53043 build(deps): bump numpy from 2.4.0 to 2.4.1
Bumps [numpy](https://github.com/numpy/numpy) from 2.4.0 to 2.4.1.
- [Release notes](https://github.com/numpy/numpy/releases)
- [Changelog](https://github.com/numpy/numpy/blob/main/doc/RELEASE_WALKTHROUGH.rst)
- [Commits](https://github.com/numpy/numpy/compare/v2.4.0...v2.4.1)

---
updated-dependencies:
- dependency-name: numpy
  dependency-version: 2.4.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-01-13 03:47:36 +00:00
19 changed files with 238 additions and 65 deletions

View File

@@ -2,6 +2,14 @@ TWBlue Changelog
## changes in this version ## changes in this version
* 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
In this version, we have focused on expanding content management capabilities within Mastodon. It is now possible to edit sent posts and schedule them for future publication. Additionally, support for reading quoted posts has been implemented, and a new buffer for server announcements is available. On the Core side, visual stability has been prioritized to ensure proper window display, along with an expansion of keyboard shortcuts. In this version, we have focused on expanding content management capabilities within Mastodon. It is now possible to edit sent posts and schedule them for future publication. Additionally, support for reading quoted posts has been implemented, and a new buffer for server announcements is available. On the Core side, visual stability has been prioritized to ensure proper window display, along with an expansion of keyboard shortcuts.
* Core: * Core:

View File

@@ -23,7 +23,7 @@ libretranslatepy==2.1.4
lief==0.15.1 lief==0.15.1
Markdown==3.10 Markdown==3.10
Mastodon.py==2.1.4 Mastodon.py==2.1.4
numpy==2.4.0 numpy==2.4.1
oauthlib==3.3.1 oauthlib==3.3.1
packaging==25.0 packaging==25.0
pillow==12.1.0 pillow==12.1.0
@@ -43,7 +43,7 @@ requests==2.32.5
requests-oauthlib==2.0.0 requests-oauthlib==2.0.0
requests-toolbelt==1.0.0 requests-toolbelt==1.0.0
rfc3986==2.0.0 rfc3986==2.0.0
setuptools==80.9.0 setuptools==80.10.2
six==1.17.0 six==1.17.0
sniffio==1.3.1 sniffio==1.3.1
sound_lib @ git+https://github.com/accessibleapps/sound_lib@a439f0943fb95ee7b6ba24f51a686f47c4ad66b2 sound_lib @ git+https://github.com/accessibleapps/sound_lib@a439f0943fb95ee7b6ba24f51a686f47c4ad66b2

View File

@@ -126,14 +126,16 @@ class BaseBuffer(base.Buffer):
min_id = None min_id = None
# toDo: Implement reverse timelines properly here. # toDo: Implement reverse timelines properly here.
if (self.name != "favorites" and self.name != "bookmarks") and self.name in self.session.db and len(self.session.db[self.name]) > 0: if (self.name != "favorites" and self.name != "bookmarks") and self.name in self.session.db and len(self.session.db[self.name]) > 0:
if self.session.settings["general"]["reverse_timelines"]: # We use the maximum ID present in the buffer to ensure we only request posts
min_id = self.session.db[self.name][0].id # that are newer than our most recent chronological post.
else: # This prevents old pinned posts from pulling in hundreds of previous statuses.
min_id = self.session.db[self.name][-1].id min_id = max(item.id for item in self.session.db[self.name])
# loads pinned posts from user accounts. # loads pinned posts from user accounts.
# Load those posts only when there are no items previously loaded. # Load those posts only when there are no items previously loaded.
if "-timeline" in self.name and "account_statuses" in self.function and len(self.session.db.get(self.name, [])) == 0: if "-timeline" in self.name and "account_statuses" in self.function and len(self.session.db.get(self.name, [])) == 0:
pinned_posts = self.session.api.account_statuses(pinned=True, limit=count, *self.args, **self.kwargs) pinned_posts = self.session.api.account_statuses(pinned=True, limit=count, *self.args, **self.kwargs)
for p in pinned_posts:
p["pinned"] = True
pinned_posts.reverse() pinned_posts.reverse()
else: else:
pinned_posts = None pinned_posts = None
@@ -182,10 +184,17 @@ class BaseBuffer(base.Buffer):
def get_more_items(self): def get_more_items(self):
elements = [] elements = []
if self.session.settings["general"]["reverse_timelines"] == False: if len(self.session.db[self.name]) == 0:
max_id = self.session.db[self.name][0].id return
# We use the minimum ID in the buffer to correctly request the next page of older items.
# This prevents old pinned posts from causing us to skip chronological posts.
# We try to exclude pinned posts from this calculation as they are usually outliers at the top.
unpinned_ids = [item.id for item in self.session.db[self.name] if not getattr(item, "pinned", False)]
if unpinned_ids:
max_id = min(unpinned_ids)
else: else:
max_id = self.session.db[self.name][-1].id max_id = min(item.id for item in self.session.db[self.name])
try: try:
items = getattr(self.session.api, self.function)(max_id=max_id, limit=self.session.settings["general"]["max_posts_per_call"], *self.args, **self.kwargs) items = getattr(self.session.api, self.function)(max_id=max_id, limit=self.session.settings["general"]["max_posts_per_call"], *self.args, **self.kwargs)
except Exception as e: except Exception as e:
@@ -311,8 +320,10 @@ class BaseBuffer(base.Buffer):
widgetUtils.connect_event(menu, widgetUtils.MENU, self.user_actions, menuitem=menu.userActions) widgetUtils.connect_event(menu, widgetUtils.MENU, self.user_actions, menuitem=menu.userActions)
if self.can_share() == True: if self.can_share() == True:
widgetUtils.connect_event(menu, widgetUtils.MENU, self.share_item, menuitem=menu.boost) widgetUtils.connect_event(menu, widgetUtils.MENU, self.share_item, menuitem=menu.boost)
widgetUtils.connect_event(menu, widgetUtils.MENU, self.quote, menuitem=menu.quote)
else: else:
menu.boost.Enable(False) 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.fav, menuitem=menu.fav)
widgetUtils.connect_event(menu, widgetUtils.MENU, self.unfav, menuitem=menu.unfav) widgetUtils.connect_event(menu, widgetUtils.MENU, self.unfav, menuitem=menu.unfav)
widgetUtils.connect_event(menu, widgetUtils.MENU, self.mute_conversation, menuitem=menu.mute) widgetUtils.connect_event(menu, widgetUtils.MENU, self.mute_conversation, menuitem=menu.mute)
@@ -442,11 +453,30 @@ class BaseBuffer(base.Buffer):
id = item.id id = item.id
if self.session.settings["general"]["boost_mode"] == "ask": if self.session.settings["general"]["boost_mode"] == "ask":
answer = mastodon_dialogs.boost_question() answer = mastodon_dialogs.boost_question()
if answer == True: if answer == 1:
self._direct_boost(id) self._direct_boost(id)
elif answer == 2:
self.quote(item=item)
else: else:
self._direct_boost(id) 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): def _direct_boost(self, id):
item = self.session.api_call(call_name="status_reblog", _sound="retweet_send.ogg", id=id) item = self.session.api_call(call_name="status_reblog", _sound="retweet_send.ogg", id=id)

View File

@@ -33,10 +33,7 @@ class CommunityBuffer(base.BaseBuffer):
min_id = None min_id = None
# toDo: Implement reverse timelines properly here. # toDo: Implement reverse timelines properly here.
if self.name in self.session.db and len(self.session.db[self.name]) > 0: if self.name in self.session.db and len(self.session.db[self.name]) > 0:
if self.session.settings["general"]["reverse_timelines"]: min_id = max(item.id for item in self.session.db[self.name])
min_id = self.session.db[self.name][0].id
else:
min_id = self.session.db[self.name][-1].id
try: try:
results = self.community_api.timeline(timeline=self.timeline, min_id=min_id, limit=count, *self.args, **self.kwargs) results = self.community_api.timeline(timeline=self.timeline, min_id=min_id, limit=count, *self.args, **self.kwargs)
results.reverse() results.reverse()
@@ -55,10 +52,15 @@ class CommunityBuffer(base.BaseBuffer):
def get_more_items(self): def get_more_items(self):
elements = [] elements = []
if self.session.settings["general"]["reverse_timelines"] == False: if len(self.session.db[self.name]) == 0:
max_id = self.session.db[self.name][0].id return
unpinned_ids = [item.id for item in self.session.db[self.name] if not getattr(item, "pinned", False)]
if unpinned_ids:
max_id = min(unpinned_ids)
else: else:
max_id = self.session.db[self.name][-1].id max_id = min(item.id for item in self.session.db[self.name])
try: try:
items = self.community_api.timeline(timeline=self.timeline, max_id=max_id, limit=self.session.settings["general"]["max_posts_per_call"], *self.args, **self.kwargs) items = self.community_api.timeline(timeline=self.timeline, max_id=max_id, limit=self.session.settings["general"]["max_posts_per_call"], *self.args, **self.kwargs)
except Exception as e: except Exception as e:

View File

@@ -42,10 +42,22 @@ class MentionsBuffer(BaseBuffer):
def get_more_items(self): def get_more_items(self):
elements = [] elements = []
if self.session.settings["general"]["reverse_timelines"] == False: if len(self.session.db[self.name]) == 0:
max_id = self.session.db[self.name][0].id return
else:
max_id = self.session.db[self.name][-1].id # In mentions buffer, items are notification objects which don't have 'pinned' attribute directly.
# But we check the status attached to the notification if it exists.
# However, notifications are strictly chronological usually. Pinned mentions don't exist?
# But let's stick to the safe ID extraction.
# The logic here is tricky because self.session.db stores notification objects, but sometimes just dicts?
# Let's assume they are objects with 'id' attribute.
# Notifications don't have 'pinned', so we just take the min ID.
# But wait, did I change this file previously to use min()? Yes.
# Is there any case where a notification ID is "pinned" (old)? No.
# So min() should be fine here. But for consistency with other buffers if any weird logic exists...
# Actually, let's keep min() as notifications don't support pinning.
max_id = min(item.id for item in self.session.db[self.name])
try: try:
items = getattr(self.session.api, self.function)(max_id=max_id, limit=self.session.settings["general"]["max_posts_per_call"], types=["mention"], *self.args, **self.kwargs) items = getattr(self.session.api, self.function)(max_id=max_id, limit=self.session.settings["general"]["max_posts_per_call"], types=["mention"], *self.args, **self.kwargs)
except Exception as e: except Exception as e:

View File

@@ -33,10 +33,7 @@ class SearchBuffer(BaseBuffer):
self.execution_time = current_time self.execution_time = current_time
min_id = None min_id = None
if self.name in self.session.db and len(self.session.db[self.name]) > 0: if self.name in self.session.db and len(self.session.db[self.name]) > 0:
if self.session.settings["general"]["reverse_timelines"]: min_id = max(item.id for item in self.session.db[self.name])
min_id = self.session.db[self.name][0].id
else:
min_id = self.session.db[self.name][-1].id
try: try:
results = getattr(self.session.api, self.function)(min_id=min_id, **self.kwargs) results = getattr(self.session.api, self.function)(min_id=min_id, **self.kwargs)
except Exception as mess: except Exception as mess:

View File

@@ -35,6 +35,7 @@ class Handler(object):
compose=_("&Post"), compose=_("&Post"),
reply=_("Re&ply"), reply=_("Re&ply"),
share=_("&Boost"), share=_("&Boost"),
quote=_("&Quote"),
fav=_("&Add to favorites"), fav=_("&Add to favorites"),
unfav=_("Remove from favorites"), unfav=_("Remove from favorites"),
view=_("&Show post"), view=_("&Show post"),

View File

@@ -10,7 +10,7 @@ import languageHandler
from twitter_text import parse_tweet, config from twitter_text import parse_tweet, config
from mastodon import MastodonError from mastodon import MastodonError
from controller import messages from controller import messages
from sessions.mastodon import templates from sessions.mastodon import templates, utils
from wxUI.dialogs.mastodon import postDialogs from wxUI.dialogs.mastodon import postDialogs
from extra.autocompletionUsers import completion from extra.autocompletionUsers import completion
from . import userList from . import userList
@@ -282,10 +282,7 @@ class editPost(post):
# Extract text from post # Extract text from post
if item.reblog != None: if item.reblog != None:
item = item.reblog item = item.reblog
text = item.content text = utils.html_filter(item.content)
# Remove HTML tags from content
import re
text = re.sub('<[^<]+?>', '', text)
# Initialize parent class # Initialize parent class
super(editPost, self).__init__(session, title, caption, text=text, *args, **kwargs) super(editPost, self).__init__(session, title, caption, text=text, *args, **kwargs)
# Store the post ID for editing # Store the post ID for editing

View File

@@ -23,7 +23,6 @@ url = string(default="control+win+b")
go_home = string(default="control+win+home") go_home = string(default="control+win+home")
go_end = string(default="control+win+end") go_end = string(default="control+win+end")
delete = string(default="control+win+delete") delete = string(default="control+win+delete")
edit_post = string(default="")
clear_buffer = string(default="control+win+shift+delete") clear_buffer = string(default="control+win+shift+delete")
repeat_item = string(default="control+win+space") repeat_item = string(default="control+win+space")
copy_to_clipboard = string(default="control+win+shift+c") copy_to_clipboard = string(default="control+win+shift+c")
@@ -38,3 +37,12 @@ ocr_image = string(default="win+alt+o")
open_in_browser = string(default="alt+control+win+return") open_in_browser = string(default="alt+control+win+return")
add_alias=string(default="") add_alias=string(default="")
vote=string(default="alt+win+shift+v") vote=string(default="alt+win+shift+v")
edit_post=string(default="")
open_favs_timeline=string(default="")
community_timeline=string(default="")
seekLeft=string(default="")
seekRight=string(default="")
manage_aliases=string(default="")
create_filter=string(default="")
manage_filters=string(default="")
manage_accounts=string(default="")

View File

@@ -33,7 +33,6 @@ go_page_up = string(default="control+win+pageup")
go_page_down = string(default="control+win+pagedown") go_page_down = string(default="control+win+pagedown")
update_profile = string(default="control+win+shift+p") update_profile = string(default="control+win+shift+p")
delete = string(default="control+win+delete") delete = string(default="control+win+delete")
edit_post = string(default="")
clear_buffer = string(default="control+win+shift+delete") clear_buffer = string(default="control+win+shift+delete")
repeat_item = string(default="control+win+space") repeat_item = string(default="control+win+space")
copy_to_clipboard = string(default="control+win+shift+c") copy_to_clipboard = string(default="control+win+shift+c")
@@ -57,3 +56,12 @@ ocr_image = string(default="win+alt+o")
open_in_browser = string(default="alt+control+win+return") open_in_browser = string(default="alt+control+win+return")
add_alias=string(default="") add_alias=string(default="")
vote=string(default="alt+win+shift+v") vote=string(default="alt+win+shift+v")
edit_post=string(default="")
open_favs_timeline=string(default="")
community_timeline=string(default="")
seekLeft=string(default="")
seekRight=string(default="")
manage_aliases=string(default="")
create_filter=string(default="")
manage_filters=string(default="")
manage_accounts=string(default="")

View File

@@ -33,7 +33,6 @@ go_page_up = string(default="control+win+pageup")
go_page_down = string(default="control+win+pagedown") go_page_down = string(default="control+win+pagedown")
update_profile = string(default="alt+win+p") update_profile = string(default="alt+win+p")
delete = string(default="alt+win+delete") delete = string(default="alt+win+delete")
edit_post = string(default="")
clear_buffer = string(default="alt+win+shift+delete") clear_buffer = string(default="alt+win+shift+delete")
repeat_item = string(default="alt+win+space") repeat_item = string(default="alt+win+space")
copy_to_clipboard = string(default="alt+win+shift+c") copy_to_clipboard = string(default="alt+win+shift+c")
@@ -60,3 +59,12 @@ add_alias=string(default="")
mute_conversation=string(default="control+alt+win+back") mute_conversation=string(default="control+alt+win+back")
find = string(default="control+win+{") find = string(default="control+win+{")
vote=string(default="alt+win+shift+v") vote=string(default="alt+win+shift+v")
edit_post=string(default="")
open_favs_timeline=string(default="")
community_timeline=string(default="")
seekLeft=string(default="")
seekRight=string(default="")
manage_aliases=string(default="")
create_filter=string(default="")
manage_filters=string(default="")
manage_accounts=string(default="")

View File

@@ -33,7 +33,6 @@ go_page_up = string(default="control+win+pageup")
go_page_down = string(default="control+win+pagedown") go_page_down = string(default="control+win+pagedown")
update_profile = string(default="alt+win+p") update_profile = string(default="alt+win+p")
delete = string(default="alt+win+delete") delete = string(default="alt+win+delete")
edit_post = string(default="")
clear_buffer = string(default="alt+win+shift+delete") clear_buffer = string(default="alt+win+shift+delete")
repeat_item = string(default="control+alt+win+space") repeat_item = string(default="control+alt+win+space")
copy_to_clipboard = string(default="alt+win+shift+c") copy_to_clipboard = string(default="alt+win+shift+c")
@@ -60,3 +59,12 @@ add_alias=string(default="")
mute_conversation=string(default="control+alt+win+back") mute_conversation=string(default="control+alt+win+back")
find = string(default="control+win+{") find = string(default="control+win+{")
vote=string(default="alt+win+shift+v") vote=string(default="alt+win+shift+v")
edit_post=string(default="")
open_favs_timeline=string(default="")
community_timeline=string(default="")
seekLeft=string(default="")
seekRight=string(default="")
manage_aliases=string(default="")
create_filter=string(default="")
manage_filters=string(default="")
manage_accounts=string(default="")

View File

@@ -59,3 +59,12 @@ open_in_browser = string(default="alt+control+win+return")
add_alias=string(default="") add_alias=string(default="")
mute_conversation=string(default="alt+win+shift+delete") mute_conversation=string(default="alt+win+shift+delete")
vote=string(default="alt+win+shift+v") vote=string(default="alt+win+shift+v")
edit_post=string(default="")
open_favs_timeline=string(default="")
community_timeline=string(default="")
seekLeft=string(default="")
seekRight=string(default="")
manage_aliases=string(default="")
create_filter=string(default="")
manage_filters=string(default="")
manage_accounts=string(default="")

View File

@@ -34,7 +34,6 @@ go_page_up = string(default="control+win+pageup")
go_page_down = string(default="control+win+pagedown") go_page_down = string(default="control+win+pagedown")
update_profile = string(default="alt+win+p") update_profile = string(default="alt+win+p")
delete = string(default="control+win+delete") delete = string(default="control+win+delete")
edit_post = string(default="")
clear_buffer = string(default="control+win+shift+delete") clear_buffer = string(default="control+win+shift+delete")
repeat_item = string(default="control+win+space") repeat_item = string(default="control+win+space")
copy_to_clipboard = string(default="control+win+shift+c") copy_to_clipboard = string(default="control+win+shift+c")
@@ -61,3 +60,12 @@ open_in_browser = string(default="alt+control+win+return")
add_alias=string(default="") add_alias=string(default="")
mute_conversation=string(default="alt+win+shift+delete") mute_conversation=string(default="alt+win+shift+delete")
vote=string(default="alt+win+shift+v") vote=string(default="alt+win+shift+v")
edit_post=string(default="")
open_favs_timeline=string(default="")
community_timeline=string(default="")
seekLeft=string(default="")
seekRight=string(default="")
manage_aliases=string(default="")
create_filter=string(default="")
manage_filters=string(default="")
manage_accounts=string(default="")

View File

@@ -29,7 +29,7 @@ actions = {
"go_end": _(u"Jump to the last element of the current buffer"), "go_end": _(u"Jump to the last element of the current buffer"),
"go_page_up": _(u"Jump 20 elements up in the current buffer"), "go_page_up": _(u"Jump 20 elements up in the current buffer"),
"go_page_down": _(u"Jump 20 elements down in the current buffer"), "go_page_down": _(u"Jump 20 elements down in the current buffer"),
# "update_profile": _(u"Edit profile"), "update_profile": _(u"Edit profile"),
"delete": _("Delete post"), "delete": _("Delete post"),
"clear_buffer": _(u"Empty the current buffer"), "clear_buffer": _(u"Empty the current buffer"),
"repeat_item": _(u"Repeat last item"), "repeat_item": _(u"Repeat last item"),
@@ -55,4 +55,14 @@ actions = {
"ocr_image": _(u"Extracts the text from a picture and displays the result in a dialog."), "ocr_image": _(u"Extracts the text from a picture and displays the result in a dialog."),
"add_alias": _("Adds an alias to an user"), "add_alias": _("Adds an alias to an user"),
"mute_conversation": _("Mute/Unmute conversation"), "mute_conversation": _("Mute/Unmute conversation"),
"edit_post": _(u"Edit the selected post"),
"vote": _(u"Vote in the selected poll"),
"open_favs_timeline": _(u"Open favorites timeline"),
"community_timeline": _(u"Open local/federated timeline"),
"seekLeft": _(u"Seek media backward"),
"seekRight": _(u"Seek media forward"),
"manage_aliases": _(u"Manage user aliases"),
"create_filter": _(u"Create a new filter"),
"manage_filters": _(u"Manage filters"),
"manage_accounts": _(u"Manage accounts"),
} }

View File

@@ -217,38 +217,69 @@ class Session(base.baseSession):
self.sound.play(_sound) self.sound.play(_sound)
return val 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. """ """ Convenience function to send a thread. """
in_reply_to_id = reply_to in_reply_to_id = reply_to
for obj in posts: for obj in posts:
text = obj.get("text") text = obj.get("text")
scheduled_at = obj.get("scheduled_at") 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: 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": 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"]) 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: else:
for i in obj["attachments"]: for i in obj["attachments"]:
media = self.api_call("media_post", media_file=i["file"], description=i["description"], synchronous=True) media = self.api_call("media_post", media_file=i["file"], description=i["description"], synchronous=True)
media_ids.append(media.id) 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: 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 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=[]): 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. """ Convenience function to edit a post. Only the first item in posts list is used as threads cannot be edited.

View File

@@ -2,11 +2,43 @@
import wx import wx
import application 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(): def boost_question():
result = False dlg = BoostDialog()
dlg = wx.MessageDialog(None, _("Would you like to share this post?"), _("Boost"), wx.YES_NO|wx.ICON_QUESTION) dlg.ShowModal()
if dlg.ShowModal() == wx.ID_YES: result = dlg.result
result = True
dlg.Destroy() dlg.Destroy()
return result return result

View File

@@ -6,6 +6,8 @@ class base(wx.Menu):
super(base, self).__init__() super(base, self).__init__()
self.boost = wx.MenuItem(self, wx.ID_ANY, _("&Boost")) self.boost = wx.MenuItem(self, wx.ID_ANY, _("&Boost"))
self.Append(self.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.reply = wx.MenuItem(self, wx.ID_ANY, _(u"Re&ply"))
self.Append(self.reply) self.Append(self.reply)
self.edit = wx.MenuItem(self, wx.ID_ANY, _(u"&Edit")) self.edit = wx.MenuItem(self, wx.ID_ANY, _(u"&Edit"))
@@ -38,6 +40,8 @@ class notification(wx.Menu):
if item in valid_types: if item in valid_types:
self.boost = wx.MenuItem(self, wx.ID_ANY, _("&Boost")) self.boost = wx.MenuItem(self, wx.ID_ANY, _("&Boost"))
self.Append(self.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.reply = wx.MenuItem(self, wx.ID_ANY, _(u"Re&ply"))
self.Append(self.reply) self.Append(self.reply)
self.edit = wx.MenuItem(self, wx.ID_ANY, _(u"&Edit")) self.edit = wx.MenuItem(self, wx.ID_ANY, _(u"&Edit"))

View File

@@ -1,9 +1,9 @@
{ {
"current_version": "2025.03.08", "current_version": "2026.01.13",
"description": "Added support for editing and scheduling Mastodon posts, improved quoted posts reading, and added server announcements buffer. Includes visual stability fixes and keyboard shortcut improvements.", "description": "Added support for editing and scheduling Mastodon posts, improved quoted posts reading, and added server announcements buffer. Includes visual stability fixes and keyboard shortcut improvements.",
"date": "2026-01-12", "date": "2026-01-12",
"downloads": "downloads":
{ {
"Windows64": "https://github.com/MCV-Software/TWBlue/releases/download/v2026.01.12/TWBlue_portable_v2026.01.12.zip" "Windows64": "https://github.com/MCV-Software/TWBlue/releases/download/v2026.01.13/TWBlue_portable_v2026.01.13.zip"
} }
} }