Merge branch 'next-gen' of github.com:mcv-software/twblue into next-gen

This commit is contained in:
2026-01-11 00:30:23 -06:00
21 changed files with 688 additions and 31 deletions

View File

@@ -280,6 +280,12 @@ class BaseBuffer(base.Buffer):
return
menu = menus.base()
widgetUtils.connect_event(menu, widgetUtils.MENU, self.reply, menuitem=menu.reply)
# Enable/disable edit based on whether the post belongs to the user
item = self.get_item()
if item and item.account.id == self.session.db["user_id"] and item.reblog == None:
widgetUtils.connect_event(menu, widgetUtils.MENU, self.edit_status, menuitem=menu.edit)
else:
menu.edit.Enable(False)
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)
@@ -501,6 +507,49 @@ class BaseBuffer(base.Buffer):
log.exception("")
self.session.db[self.name] = items
def edit_status(self, event=None, item=None, *args, **kwargs):
if item == None:
item = self.get_item()
# Check if the post belongs to the current user
if item.account.id != self.session.db["user_id"] or item.reblog != None:
output.speak(_("You can only edit your own posts."))
return
# Check if post has a poll with votes - warn user before proceeding
if hasattr(item, 'poll') and item.poll is not None:
votes_count = item.poll.votes_count if hasattr(item.poll, 'votes_count') else 0
if votes_count > 0:
# Show confirmation dialog
warning_title = _("Warning: Poll with votes")
warning_message = _("This post contains a poll with {votes} votes.\n\n"
"According to Mastodon's API, editing this post will reset ALL votes to zero, "
"even if you don't modify the poll itself.\n\n"
"Do you want to continue editing?").format(votes=votes_count)
dialog = wx.MessageDialog(self.buffer, warning_message, warning_title,
wx.YES_NO | wx.NO_DEFAULT | wx.ICON_WARNING)
result = dialog.ShowModal()
dialog.Destroy()
if result != wx.ID_YES:
output.speak(_("Edit cancelled"))
return
# Log item info for debugging
log.debug("Editing status: id={}, has_media_attachments={}, media_count={}".format(
item.id,
hasattr(item, 'media_attachments'),
len(item.media_attachments) if hasattr(item, 'media_attachments') else 0
))
# Create edit dialog with existing post data
title = _("Edit post")
caption = _("Edit your post here")
post = messages.editPost(session=self.session, item=item, title=title, caption=caption)
response = post.message.ShowModal()
if response == wx.ID_OK:
post_data = post.get_data()
# Call edit_post method in session
# Note: visibility and language cannot be changed when editing per Mastodon API
call_threaded(self.session.edit_post, post_id=post.post_id, posts=post_data)
if hasattr(post.message, "destroy"):
post.message.destroy()
def user_details(self):
item = self.get_item()
pass

View File

@@ -161,6 +161,13 @@ class NotificationsBuffer(BaseBuffer):
menu = menus.notification(notification.type)
if self.is_post():
widgetUtils.connect_event(menu, widgetUtils.MENU, self.reply, menuitem=menu.reply)
# Enable/disable edit based on whether the post belongs to the user
if hasattr(menu, 'edit'):
status = self.get_post()
if status and status.account.id == self.session.db["user_id"] and status.reblog == None:
widgetUtils.connect_event(menu, widgetUtils.MENU, self.edit_status, menuitem=menu.edit)
else:
menu.edit.Enable(False)
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)

View File

@@ -449,6 +449,15 @@ class Controller(object):
buffer = self.search_buffer(buffer.name, buffer.account)
buffer.destroy_status()
def edit_post(self, *args, **kwargs):
""" Edits a post in the current buffer.
Users can only edit their own posts."""
buffer = self.view.get_current_buffer()
if hasattr(buffer, "account"):
buffer = self.search_buffer(buffer.name, buffer.account)
if hasattr(buffer, "edit_status"):
buffer.edit_status()
def exit(self, *args, **kwargs):
if config.app["app-settings"]["ask_at_exit"] == True:
answer = commonMessageDialogs.exit_dialog(self.view)

View File

@@ -48,7 +48,7 @@ class Handler(object):
addAlias=_("Add a&lias"),
addToList=None,
removeFromList=None,
details=_("Show user profile"),
details=_("S&how user profile"),
favs=None,
# In buffer Menu.
community_timeline =_("Create c&ommunity timeline"),

View File

@@ -2,6 +2,7 @@
import os
import re
import wx
import logging
import widgetUtils
import config
import output
@@ -14,6 +15,8 @@ from wxUI.dialogs.mastodon import postDialogs
from extra.autocompletionUsers import completion
from . import userList
log = logging.getLogger("controller.mastodon.messages")
def character_count(post_text, post_cw, character_limit=500):
# We will use text for counting character limit only.
full_text = post_text+post_cw
@@ -262,6 +265,108 @@ class post(messages.basicMessage):
visibility_setting = visibility_settings.index(setting)
self.message.visibility.SetSelection(setting)
class editPost(post):
def __init__(self, session, item, title, caption, *args, **kwargs):
""" Initialize edit dialog with existing post data.
Note: Per Mastodon API, visibility and language cannot be changed when editing.
These fields will be displayed but disabled in the UI.
"""
# Extract text from post
if item.reblog != None:
item = item.reblog
text = item.content
# Remove HTML tags from content
import re
text = re.sub('<[^<]+?>', '', text)
# Initialize parent class
super(editPost, self).__init__(session, title, caption, text=text, *args, **kwargs)
# Store the post ID for editing
self.post_id = item.id
# Set visibility (read-only, cannot be changed)
visibility_settings = dict(public=0, unlisted=1, private=2, direct=3)
self.message.visibility.SetSelection(visibility_settings.get(item.visibility, 0))
self.message.visibility.Enable(False) # Disable as it cannot be edited
# Set language (read-only, cannot be changed)
if item.language:
self.set_language(item.language)
self.message.language.Enable(False) # Disable as it cannot be edited
# Set sensitive content and spoiler
if item.sensitive:
self.message.sensitive.SetValue(True)
if item.spoiler_text:
self.message.spoiler.ChangeValue(item.spoiler_text)
self.message.on_sensitivity_changed()
# Load existing poll (if any)
# Note: You cannot have both media and a poll, so check poll first
if hasattr(item, 'poll') and item.poll is not None:
log.debug("Loading existing poll for post {}".format(self.post_id))
poll = item.poll
# Extract poll options (just the text, not the votes)
poll_options = [option.title for option in poll.options]
# Calculate expires_in based on current time and expires_at
# For editing, we need to provide a new expiration time
# Since we can't get the original expires_in, use a default or let user configure
# For now, use 1 day (86400 seconds) as default
expires_in = 86400
if hasattr(poll, 'expires_at') and poll.expires_at and not poll.expired:
# Calculate remaining time if poll hasn't expired
from dateutil import parser as date_parser
import datetime
try:
expires_at = poll.expires_at
if isinstance(expires_at, str):
expires_at = date_parser.parse(expires_at)
now = datetime.datetime.now(datetime.timezone.utc)
remaining = (expires_at - now).total_seconds()
if remaining > 0:
expires_in = int(remaining)
except Exception as e:
log.warning("Could not calculate poll expiration: {}".format(e))
poll_info = {
"type": "poll",
"file": "",
"description": _("Poll with {} options").format(len(poll_options)),
"options": poll_options,
"expires_in": expires_in,
"multiple": poll.multiple if hasattr(poll, 'multiple') else False,
"hide_totals": poll.voters_count == 0 if hasattr(poll, 'voters_count') else False
}
self.attachments.append(poll_info)
self.message.add_item(item=[poll_info["file"], poll_info["type"], poll_info["description"]])
log.debug("Loaded poll with {} options. WARNING: Editing will reset all votes!".format(len(poll_options)))
# Load existing media attachments (only if no poll)
elif hasattr(item, 'media_attachments'):
log.debug("Loading existing media attachments for post {}".format(self.post_id))
log.debug("Item has media_attachments attribute, count: {}".format(len(item.media_attachments)))
if len(item.media_attachments) > 0:
for media in item.media_attachments:
log.debug("Processing media: id={}, type={}, url={}".format(media.id, media.type, media.url))
media_info = {
"id": media.id, # Keep the existing media ID
"type": media.type,
"file": media.url, # URL of existing media
"description": media.description or ""
}
# Include focus point if available
if hasattr(media, 'meta') and media.meta and 'focus' in media.meta:
focus = media.meta['focus']
media_info["focus"] = (focus.get('x'), focus.get('y'))
log.debug("Added focus point: {}".format(media_info["focus"]))
self.attachments.append(media_info)
# Display in the attachment list
display_name = media.url.split('/')[-1]
log.debug("Adding item to UI: name={}, type={}, desc={}".format(display_name, media.type, media.description or ""))
self.message.add_item(item=[display_name, media.type, media.description or ""])
log.debug("Total attachments loaded: {}".format(len(self.attachments)))
else:
log.debug("media_attachments list is empty")
else:
log.debug("Item has no poll or media attachments")
# Update text processor to reflect the loaded content
self.text_processor()
class viewPost(post):
def __init__(self, session, post, offset_hours=0, date="", item_url=""):
self.session = session

View File

@@ -13,8 +13,8 @@ class autocompletionManageDialog(widgetUtils.BaseDialog):
self.users = widgets.list(panel, _(u"Username"), _(u"Name"), style=wx.LC_REPORT)
sizer.Add(label, 0, wx.ALL, 5)
sizer.Add(self.users.list, 0, wx.ALL, 5)
self.add = wx.Button(panel, -1, _(u"Add user"))
self.remove = wx.Button(panel, -1, _(u"Remove user"))
self.add = wx.Button(panel, -1, _(u"&Add user"))
self.remove = wx.Button(panel, -1, _(u"&Remove user"))
optionsBox = wx.BoxSizer(wx.HORIZONTAL)
optionsBox.Add(self.add, 0, wx.ALL, 5)
optionsBox.Add(self.remove, 0, wx.ALL, 5)

View File

@@ -23,6 +23,7 @@ url = string(default="control+win+b")
go_home = string(default="control+win+home")
go_end = string(default="control+win+end")
delete = string(default="control+win+delete")
edit_post = string(default="")
clear_buffer = string(default="control+win+shift+delete")
repeat_item = string(default="control+win+space")
copy_to_clipboard = string(default="control+win+shift+c")

View File

@@ -33,6 +33,7 @@ go_page_up = string(default="control+win+pageup")
go_page_down = string(default="control+win+pagedown")
update_profile = string(default="control+win+shift+p")
delete = string(default="control+win+delete")
edit_post = string(default="")
clear_buffer = string(default="control+win+shift+delete")
repeat_item = string(default="control+win+space")
copy_to_clipboard = string(default="control+win+shift+c")

View File

@@ -33,6 +33,7 @@ go_page_up = string(default="control+win+pageup")
go_page_down = string(default="control+win+pagedown")
update_profile = string(default="alt+win+p")
delete = string(default="alt+win+delete")
edit_post = string(default="")
clear_buffer = string(default="alt+win+shift+delete")
repeat_item = string(default="alt+win+space")
copy_to_clipboard = string(default="alt+win+shift+c")

View File

@@ -33,6 +33,7 @@ go_page_up = string(default="control+win+pageup")
go_page_down = string(default="control+win+pagedown")
update_profile = string(default="alt+win+p")
delete = string(default="alt+win+delete")
edit_post = string(default="")
clear_buffer = string(default="alt+win+shift+delete")
repeat_item = string(default="control+alt+win+space")
copy_to_clipboard = string(default="alt+win+shift+c")

View File

@@ -34,6 +34,7 @@ go_page_up = string(default="control+win+pageup")
go_page_down = string(default="control+win+pagedown")
update_profile = string(default="alt+win+p")
delete = string(default="control+win+delete")
edit_post = string(default="")
clear_buffer = string(default="control+win+shift+delete")
repeat_item = string(default="control+win+space")
copy_to_clipboard = string(default="control+win+shift+c")

View File

@@ -17,6 +17,11 @@ def compose_post(post, db, settings, relative_times, show_screen_names, safe=Tru
text = _("Boosted from @{}: {}").format(post.reblog.account.acct, templates.process_text(post.reblog, safe=safe))
else:
text = templates.process_text(post, safe=safe)
# Handle quoted posts
if hasattr(post, 'quote') and post.quote != None and hasattr(post.quote, 'quoted_status') and post.quote.quoted_status != None:
quoted_user = post.quote.quoted_status.account.acct
quoted_text = templates.process_text(post.quote.quoted_status, safe=safe)
text = text + " " + _("Quoting @{}: {}").format(quoted_user, quoted_text)
filtered = utils.evaluate_filters(post=post, current_context="home")
if filtered != None:
text = _("hidden by filter {}").format(filtered)

View File

@@ -248,6 +248,106 @@ class Session(base.baseSession):
pub.sendMessage("mastodon.error_post", name=self.get_name(), reply_to=reply_to, visibility=visibility, posts=posts, lang=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.
Note: According to Mastodon API, not all fields can be edited. Visibility, language, and reply context cannot be changed.
Args:
post_id: ID of the status to edit
posts: List with post data. Only first item is used.
Returns:
Updated status object or None on failure
"""
if len(posts) == 0:
log.warning("edit_post called with empty posts list")
return None
obj = posts[0]
text = obj.get("text")
if not text:
log.warning("edit_post called without text content")
return None
media_ids = []
media_attributes = []
try:
poll = None
# Handle poll attachments
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"]
)
log.debug("Editing post with poll (this will reset votes)")
# Handle media attachments
elif len(obj["attachments"]) > 0:
for i in obj["attachments"]:
# If attachment has an 'id', it's an existing media that we keep
if "id" in i:
media_ids.append(i["id"])
# If existing media has metadata to update, use generate_media_edit_attributes
if "description" in i or "focus" in i:
media_attr = self.api.generate_media_edit_attributes(
id=i["id"],
description=i.get("description"),
focus=i.get("focus")
)
media_attributes.append(media_attr)
# Otherwise it's a new file to upload
elif "file" in i:
description = i.get("description", "")
focus = i.get("focus", None)
media = self.api_call(
"media_post",
media_file=i["file"],
description=description,
focus=focus,
synchronous=True
)
media_ids.append(media.id)
log.debug("Uploaded new media with id: {}".format(media.id))
# Prepare parameters for status_update
update_params = {
"id": post_id,
"status": text,
"_sound": "tweet_send.ogg",
"sensitive": obj.get("sensitive", False),
"spoiler_text": obj.get("spoiler_text", None),
}
# Add optional parameters only if provided
if media_ids:
update_params["media_ids"] = media_ids
if media_attributes:
update_params["media_attributes"] = media_attributes
if poll:
update_params["poll"] = poll
# Call status_update API
log.debug("Editing post {} with params: {}".format(post_id, {k: v for k, v in update_params.items() if k not in ["_sound"]}))
item = self.api_call(call_name="status_update", **update_params)
if item:
log.info("Successfully edited post {}".format(post_id))
return item
except MastodonAPIError as e:
log.exception("Mastodon API error updating post {}: {}".format(post_id, str(e)))
output.speak(_("Error editing post: {}").format(str(e)))
pub.sendMessage("mastodon.error_edit", name=self.get_name(), post_id=post_id, error=str(e))
return None
except Exception as e:
log.exception("Unexpected error updating post {}: {}".format(post_id, str(e)))
output.speak(_("Error editing post: {}").format(str(e)))
return None
def get_name(self):
instance = self.settings["mastodon"]["instance"]
instance = instance.replace("https://", "")

View File

@@ -76,6 +76,13 @@ def render_post(post, template, settings, relative_times=False, offset_hours=0):
else:
text = process_text(post, safe=False)
safe_text = process_text(post)
# Handle quoted posts
if hasattr(post, 'quote') and post.quote != None and hasattr(post.quote, 'quoted_status') and post.quote.quoted_status != None:
quoted_user = post.quote.quoted_status.account.acct
quoted_text = process_text(post.quote.quoted_status, safe=False)
quoted_safe_text = process_text(post.quote.quoted_status, safe=True)
text = text + " " + _("Quoting @{}: {}").format(quoted_user, quoted_text)
safe_text = safe_text + " " + _("Quoting @{}: {}").format(quoted_user, quoted_safe_text)
filtered = utils.evaluate_filters(post=post, current_context="home")
if filtered != None:
text = _("hidden by filter {}").format(filtered)

View File

@@ -3,23 +3,47 @@ import demoji
from html.parser import HTMLParser
from datetime import datetime, timezone
url_re = re.compile('<a\s*href=[\'|"](.*?)[\'"].*?>')
url_re = re.compile(r'<a\s*href=[\'|"](.*?)[\'"].*?>')
class HTMLFilter(HTMLParser):
# Classes to ignore when parsing HTML
IGNORED_CLASSES = ["quote-inline"]
text = ""
first_paragraph = True
skip_depth = 0 # Track nesting depth of ignored elements
def handle_data(self, data):
self.text += data
# Only add data if we're not inside an ignored element
if self.skip_depth == 0:
self.text += data
def handle_starttag(self, tag, attrs):
if tag == "br":
self.text = self.text+"\n"
elif tag == "p":
if self.first_paragraph:
self.first_paragraph = False
else:
self.text = self.text+"\n\n"
# Check if this tag has a class that should be ignored
attrs_dict = dict(attrs)
tag_class = attrs_dict.get("class", "")
# Check if any ignored class is present in this tag
should_skip = any(ignored_class in tag_class for ignored_class in self.IGNORED_CLASSES)
if should_skip:
self.skip_depth += 1
elif self.skip_depth == 0: # Only process tags if we're not skipping
if tag == "br":
self.text = self.text+"\n"
elif tag == "p":
if self.first_paragraph:
self.first_paragraph = False
else:
self.text = self.text+"\n\n"
else:
# We're inside a skipped element, increment depth for nested tags
self.skip_depth += 1
def handle_endtag(self, tag):
# Decrement skip depth when closing any tag while skipping
if self.skip_depth > 0:
self.skip_depth -= 1
def html_filter(data):
f = HTMLFilter()

View File

@@ -8,6 +8,8 @@ class base(wx.Menu):
self.Append(self.boost)
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"))
self.Append(self.edit)
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"))
@@ -36,6 +38,8 @@ class notification(wx.Menu):
self.Append(self.boost)
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"))
self.Append(self.edit)
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"))

View File

@@ -141,7 +141,7 @@ class ShowUserProfile(wx.Dialog):
mainSizer.Add(privateSizer, 0, wx.ALL | wx.CENTER)
botSizer = wx.BoxSizer(wx.HORIZONTAL)
botLabel = wx.StaticText(self.panel, label=_("&Bot account: "))
botLabel = wx.StaticText(self.panel, label=_("B&ot account: "))
botText = self.createTextCtrl(bullSwitch[user.bot], (30, 30))
botSizer.Add(botLabel, wx.SizerFlags().Center())
botSizer.Add(botText, wx.SizerFlags().Center())
@@ -154,7 +154,7 @@ class ShowUserProfile(wx.Dialog):
discoverSizer.Add(discoverText, wx.SizerFlags().Center())
mainSizer.Add(discoverSizer, 0, wx.ALL | wx.CENTER)
posts = wx.Button(self.panel, label=_("{} p&osts. Click to open posts timeline").format(user.statuses_count))
posts = wx.Button(self.panel, label=_("{} pos&ts. Click to open posts timeline").format(user.statuses_count))
# posts.SetToolTip(_("Click to open {}'s posts").format(user.display_name))
posts.Bind(wx.EVT_BUTTON, self.onPost)
mainSizer.Add(posts, wx.SizerFlags().Center())

View File

@@ -119,7 +119,7 @@ class UpdateProfileDialog(wx.Dialog):
self.locked = wx.CheckBox(panel, label=_("&Private account"))
self.locked.SetValue(locked)
self.bot = wx.CheckBox(panel, label=_("&Bot account"))
self.bot = wx.CheckBox(panel, label=_("B&ot account"))
self.bot.SetValue(bot)
self.discoverable = wx.CheckBox(panel, label=_("&Discoverable account"))
self.discoverable.SetValue(discoverable)

View File

@@ -19,7 +19,7 @@ class mainFrame(wx.Frame):
self.menuitem_search = self.menubar_application.Append(wx.ID_ANY, _(u"&Search"))
self.lists = self.menubar_application.Append(wx.ID_ANY, _(u"&Lists manager"))
self.lists.Enable(False)
self.manageAliases = self.menubar_application.Append(wx.ID_ANY, _("Manage user aliases"))
self.manageAliases = self.menubar_application.Append(wx.ID_ANY, _("M&anage user aliases"))
self.keystroke_editor = self.menubar_application.Append(wx.ID_ANY, _(u"&Edit keystrokes"))
self.account_settings = self.menubar_application.Append(wx.ID_ANY, _(u"Account se&ttings"))
self.prefs = self.menubar_application.Append(wx.ID_PREFERENCES, _(u"&Global settings"))
@@ -56,7 +56,7 @@ class mainFrame(wx.Frame):
self.trends = self.menubar_buffer.Append(wx.ID_ANY, _(u"New &trending topics buffer..."))
self.filter = self.menubar_buffer.Append(wx.ID_ANY, _(u"Create a &filter"))
self.manage_filters = self.menubar_buffer.Append(wx.ID_ANY, _(u"&Manage filters"))
self.find = self.menubar_buffer.Append(wx.ID_ANY, _(u"Find a string in the currently focused buffer..."))
self.find = self.menubar_buffer.Append(wx.ID_ANY, _(u"F&ind a string in the currently focused buffer..."))
self.load_previous_items = self.menubar_buffer.Append(wx.ID_ANY, _(u"&Load previous items"))
self.menubar_buffer.AppendSeparator()
self.mute_buffer = self.menubar_buffer.AppendCheckItem(wx.ID_ANY, _(u"&Mute"))
@@ -66,8 +66,8 @@ class mainFrame(wx.Frame):
# audio menu
self.menubar_audio = wx.Menu()
self.seekLeft = self.menubar_audio.Append(wx.ID_ANY, _(u"&Seek back 5 seconds"))
self.seekRight = self.menubar_audio.Append(wx.ID_ANY, _(u"&Seek forward 5 seconds"))
self.seekLeft = self.menubar_audio.Append(wx.ID_ANY, _(u"Seek &back 5 seconds"))
self.seekRight = self.menubar_audio.Append(wx.ID_ANY, _(u"Seek &forward 5 seconds"))
# Help Menu
self.menubar_help = wx.Menu()