Terminando integración

This commit is contained in:
Jesús Pavón Abián
2026-02-01 10:42:05 +01:00
parent ec8d6ecada
commit f7f12a1c7b
13 changed files with 975 additions and 1903 deletions

View File

@@ -44,16 +44,26 @@ class Handler:
start=True,
kwargs=dict(parent=controller.view.nb, name="home_timeline", session=session)
)
# Following-only timeline (reverse-chronological)
# Home (Following-only timeline - reverse-chronological)
pub.sendMessage(
"createBuffer",
buffer_type="following_timeline",
session_type="blueski",
buffer_title=_("Following (Chronological)"),
buffer_title=_("Home"),
parent_tab=root_position,
start=False,
kwargs=dict(parent=controller.view.nb, name="following_timeline", session=session)
)
# Mentions (replies, mentions, quotes)
pub.sendMessage(
"createBuffer",
buffer_type="MentionsBuffer",
session_type="blueski",
buffer_title=_("Mentions"),
parent_tab=root_position,
start=False,
kwargs=dict(parent=controller.view.nb, name="mentions", session=session)
)
# Notifications
pub.sendMessage(
"createBuffer",
@@ -64,6 +74,16 @@ class Handler:
start=False,
kwargs=dict(parent=controller.view.nb, name="notifications", session=session)
)
# Sent posts
pub.sendMessage(
"createBuffer",
buffer_type="SentBuffer",
session_type="blueski",
buffer_title=_("Sent"),
parent_tab=root_position,
start=False,
kwargs=dict(parent=controller.view.nb, name="sent", session=session)
)
# Likes
pub.sendMessage(
"createBuffer",
@@ -84,12 +104,12 @@ class Handler:
start=False,
kwargs=dict(parent=controller.view.nb, name="followers", session=session)
)
# Following (Users)
# Followings (Users you follow)
pub.sendMessage(
"createBuffer",
buffer_type="FollowingBuffer",
session_type="blueski",
buffer_title=_("Following (Users)"),
buffer_title=_("Followings"),
parent_tab=root_position,
start=False,
kwargs=dict(parent=controller.view.nb, name="following", session=session)
@@ -115,6 +135,12 @@ class Handler:
kwargs=dict(parent=controller.view.nb, name="direct_messages", session=session)
)
# Start the background poller for real-time-like updates
try:
session.start_streaming()
except Exception:
logger.exception("Failed to start Bluesky streaming for session %s", name)
def start_buffer(self, controller, buffer):
"""Start a newly created Bluesky buffer."""
try:
@@ -343,10 +369,10 @@ class Handler:
"""Standard action for delete key / menu item"""
item = buffer.get_item()
if not item: return
uri = getattr(item, "uri", None) or (item.get("post", {}).get("uri") if isinstance(item, dict) else None)
if not uri: return
import wx
if wx.MessageBox(_("Are you sure you want to delete this post?"), _("Delete post"), wx.YES_NO | wx.ICON_QUESTION) == wx.YES:
if buffer.session.delete_post(uri):
@@ -358,3 +384,54 @@ class Handler:
else:
import output
output.speak(_("Failed to delete post."))
def search(self, controller, session):
"""Open search dialog and create search buffer for results."""
dlg = wx.TextEntryDialog(
controller.view,
_("Enter search term:"),
_("Search Bluesky")
)
if dlg.ShowModal() != wx.ID_OK:
dlg.Destroy()
return
query = dlg.GetValue().strip()
dlg.Destroy()
if not query:
return
# Create unique buffer name for this search
buffer_name = f"search_{query[:20]}"
account_name = session.get_name()
# Check if buffer already exists
existing = controller.search_buffer(buffer_name, account_name)
if existing:
# Navigate to existing buffer
index = controller.view.search(buffer_name, account_name)
if index is not None:
controller.view.change_buffer(index)
# Refresh search
existing.search_query = query
existing.start_stream(mandatory=True, play_sound=False)
return
# Create new search buffer
title = _("Search: {query}").format(query=query)
from pubsub import pub
pub.sendMessage(
"createBuffer",
buffer_type="SearchBuffer",
session_type="blueski",
buffer_title=title,
parent_tab=controller.view.search(account_name, account_name),
start=True,
kwargs=dict(
parent=controller.view.nb,
name=buffer_name,
session=session,
query=query
)
)

View File

@@ -1,4 +1,13 @@
# -*- coding: utf-8 -*-
from .timeline import HomeTimeline, FollowingTimeline, NotificationBuffer, Conversation
from .timeline import (
HomeTimeline,
FollowingTimeline,
NotificationBuffer,
Conversation,
LikesBuffer,
MentionsBuffer,
SentBuffer,
SearchBuffer,
)
from .user import FollowersBuffer, FollowingBuffer, BlocksBuffer
from .chat import ConversationListBuffer, ChatBuffer as ChatMessageBuffer

View File

@@ -123,20 +123,84 @@ class BaseBuffer(base.Buffer):
output.speak(_("Reposted."))
def on_like(self, evt):
self.toggle_favorite(confirm=True)
self.toggle_favorite(confirm=False)
def toggle_favorite(self, confirm=False, *args, **kwargs):
item = self.get_item()
if not item: return
uri = item.get("uri") if isinstance(item, dict) else getattr(item, "uri", None)
if not item:
output.speak(_("No item to like."), True)
return
def g(obj, key, default=None):
if isinstance(obj, dict):
return obj.get(key, default)
return getattr(obj, key, default)
uri = g(item, "uri")
if not uri:
post = g(item, "post") or g(item, "record")
uri = g(post, "uri") if post else None
if not uri:
output.speak(_("Could not find post identifier."), True)
return
if confirm:
if wx.MessageBox(_("Like this post?"), _("Confirm"), wx.YES_NO | wx.ICON_QUESTION) != wx.YES:
return
self.session.like(uri)
# Check if already liked
viewer = g(item, "viewer")
already_liked = g(viewer, "like") if viewer else None
if already_liked:
output.speak(_("Already liked."), True)
return
# Perform the like
like_uri = self.session.like(uri)
if not like_uri:
output.speak(_("Failed to like post."), True)
return
output.speak(_("Liked."))
# Update the viewer state in the item
if isinstance(item, dict):
if "viewer" not in item:
item["viewer"] = {}
item["viewer"]["like"] = like_uri
else:
# For SDK models, create or update viewer
if not hasattr(item, "viewer") or item.viewer is None:
# Create a simple object to hold the like state
class Viewer:
def __init__(self):
self.like = None
item.viewer = Viewer()
item.viewer.like = like_uri
# Refresh the displayed item in the list
try:
index = self.buffer.list.get_selected()
if index > -1:
# Recompose and update the list item
safe = True
relative_times = self.session.settings["general"].get("relative_times", False)
show_screen_names = self.session.settings["general"].get("show_screen_names", False)
post_data = self.compose_function(item, self.session.db, self.session.settings,
relative_times=relative_times,
show_screen_names=show_screen_names,
safe=safe)
# Update the item in place (only 3 columns: Author, Post, Date)
self.buffer.list.list.SetItem(index, 0, post_data[0]) # Author
self.buffer.list.list.SetItem(index, 1, post_data[1]) # Text (with ♥ indicator)
self.buffer.list.list.SetItem(index, 2, post_data[2]) # Date
# Note: compose_post returns 4 items but list only has 3 columns
except Exception:
log.exception("Error refreshing list item after like")
def add_to_favorites(self, *args, **kwargs):
self.toggle_favorite(confirm=False)
@@ -172,8 +236,9 @@ class BaseBuffer(base.Buffer):
if text:
try:
api = self.session._ensure_client()
dm_client = api.with_bsky_chat_proxy()
# Get or create conversation
res = api.chat.bsky.convo.get_convo_for_members({"members": [did]})
res = dm_client.chat.bsky.convo.get_convo_for_members({"members": [did]})
convo_id = res.convo.id
self.session.send_chat_message(convo_id, text)
output.speak(_("Message sent."), True)
@@ -186,6 +251,46 @@ class BaseBuffer(base.Buffer):
# If showing, we'll just open the chat buffer for now as it's more structured
self.view_chat_with_user(did, handle)
def url(self, *args, **kwargs):
item = self.get_item()
if not item: return
import webbrowser
def g(obj, key, default=None):
if isinstance(obj, dict):
return obj.get(key, default)
return getattr(obj, key, default)
uri = g(item, "uri")
author = g(item, "author") or g(g(item, "post"), "author")
handle = g(author, "handle")
if uri and handle:
# URI format: at://did:plc:xxx/app.bsky.feed.post/rkey
if "app.bsky.feed.post" in uri:
rkey = uri.split("/")[-1]
url = f"https://bsky.app/profile/{handle}/post/{rkey}"
webbrowser.open(url)
return
elif "app.bsky.feed.like" in uri:
# It's a like notification, try to get the subject
subject = g(item, "subject")
subject_uri = g(subject, "uri") if subject else None
if subject_uri:
rkey = subject_uri.split("/")[-1]
# We might not have the handle of the post author here easily if it's not in the notification
# But let's try...
# Actually, notification items usually have enough info or we can't deep direct link easily without fetching.
# For now, let's just open the profile of the liker
pass
# Fallback to profile
if handle:
url = f"https://bsky.app/profile/{handle}"
webbrowser.open(url)
return
def user_actions(self, *args, **kwargs):
pub.sendMessage("execute-action", action="follow")

View File

@@ -100,28 +100,43 @@ class FollowingTimeline(BaseBuffer):
class NotificationBuffer(BaseBuffer):
def __init__(self, *args, **kwargs):
# Override compose_func before calling super().__init__
kwargs["compose_func"] = "compose_notification"
super(NotificationBuffer, self).__init__(*args, **kwargs)
self.type = "notifications"
self.sound = "notification_received.ogg"
def create_buffer(self, parent, name):
self.buffer = BlueskiPanels.NotificationPanel(parent, name)
self.buffer = BlueskiPanels.NotificationPanel(parent, name)
self.buffer.session = self.session
def start_stream(self, mandatory=False, play_sound=True):
count = 50
api = self.session._ensure_client()
try:
res = api.app.bsky.notification.list_notifications({"limit": count})
notifs = getattr(res, "notifications", [])
items = []
# Notifications are not FeedViewPost. They have different structure.
# self.compose_function expects FeedViewPost-like structure (post, author, etc).
# We need to map them or have a different compose function.
# For now, let's skip items to avoid crash
# Or attempt to map.
except:
return 0
return 0
count = 50
try:
count = self.session.settings["general"].get("max_posts_per_call", 50)
except Exception:
pass
api = self.session._ensure_client()
if not api:
return 0
try:
res = api.app.bsky.notification.list_notifications({"limit": count})
notifications = getattr(res, "notifications", [])
if not notifications:
return 0
# Process notifications using the notification compose function
return self.process_items(list(notifications), play_sound)
except Exception:
log.exception("Error fetching Bluesky notifications")
return 0
def add_new_item(self, notification):
"""Add a single new notification from streaming/polling."""
return self.process_items([notification], play_sound=True)
class Conversation(BaseBuffer):
def __init__(self, *args, **kwargs):
@@ -207,3 +222,154 @@ class LikesBuffer(BaseBuffer):
return 0
return self.process_items(list(items), play_sound)
class MentionsBuffer(BaseBuffer):
"""Buffer for mentions and replies to the current user."""
def __init__(self, *args, **kwargs):
# Use notification compose function since mentions come from notifications
kwargs["compose_func"] = "compose_notification"
super(MentionsBuffer, self).__init__(*args, **kwargs)
self.type = "mentions"
self.sound = "mention_received.ogg"
def create_buffer(self, parent, name):
self.buffer = BlueskiPanels.NotificationPanel(parent, name)
self.buffer.session = self.session
def start_stream(self, mandatory=False, play_sound=True):
count = 50
try:
count = self.session.settings["general"].get("max_posts_per_call", 50)
except Exception:
pass
api = self.session._ensure_client()
if not api:
return 0
try:
res = api.app.bsky.notification.list_notifications({"limit": count})
notifications = getattr(res, "notifications", [])
if not notifications:
return 0
# Filter only mentions and replies
mentions = [
n for n in notifications
if getattr(n, "reason", "") in ("mention", "reply", "quote")
]
if not mentions:
return 0
return self.process_items(mentions, play_sound)
except Exception:
log.exception("Error fetching Bluesky mentions")
return 0
def add_new_item(self, notification):
"""Add a single new mention from streaming/polling."""
reason = getattr(notification, "reason", "")
if reason in ("mention", "reply", "quote"):
return self.process_items([notification], play_sound=True)
return 0
class SentBuffer(BaseBuffer):
"""Buffer for posts sent by the current user."""
def __init__(self, *args, **kwargs):
super(SentBuffer, self).__init__(*args, **kwargs)
self.type = "sent"
def create_buffer(self, parent, name):
self.buffer = BlueskiPanels.HomePanel(parent, name)
self.buffer.session = self.session
def start_stream(self, mandatory=False, play_sound=True):
count = 50
try:
count = self.session.settings["general"].get("max_posts_per_call", 50)
except Exception:
pass
api = self.session._ensure_client()
if not api or not api.me:
return 0
try:
# Get author's own posts (excluding replies)
res = api.app.bsky.feed.get_author_feed({
"actor": api.me.did,
"limit": count,
"filter": "posts_no_replies"
})
items = getattr(res, "feed", [])
if not items:
return 0
return self.process_items(list(items), play_sound)
except Exception:
log.exception("Error fetching sent posts")
return 0
class SearchBuffer(BaseBuffer):
"""Buffer for search results (posts)."""
def __init__(self, *args, **kwargs):
self.search_query = kwargs.pop("query", "")
super(SearchBuffer, self).__init__(*args, **kwargs)
self.type = "search"
def create_buffer(self, parent, name):
self.buffer = BlueskiPanels.HomePanel(parent, name)
self.buffer.session = self.session
def start_stream(self, mandatory=False, play_sound=True):
if not self.search_query:
return 0
count = 50
try:
count = self.session.settings["general"].get("max_posts_per_call", 50)
except Exception:
pass
api = self.session._ensure_client()
if not api:
return 0
try:
# Search posts
res = api.app.bsky.feed.search_posts({
"q": self.search_query,
"limit": count
})
posts = getattr(res, "posts", [])
if not posts:
return 0
# Clear existing results for new search
self.session.db[self.name] = []
self.buffer.list.clear()
return self.process_items(list(posts), play_sound)
except Exception:
log.exception("Error searching Bluesky posts")
return 0
def remove_buffer(self, force=False):
"""Search buffers can always be removed."""
try:
self.session.db.pop(self.name, None)
except Exception:
pass
return True

View File

@@ -128,6 +128,9 @@ class Controller(object):
pub.subscribe(self.mastodon_new_conversation, "mastodon.conversation_received")
pub.subscribe(self.mastodon_error_post, "mastodon.error_post")
# Bluesky specific events.
pub.subscribe(self.blueski_new_item, "blueski.new_item")
# connect application events to GUI
widgetUtils.connect_event(self.view, widgetUtils.CLOSE_EVENT, self.exit_)
widgetUtils.connect_event(self.view, widgetUtils.MENU, self.show_hide, menuitem=self.view.show_hide)
@@ -388,6 +391,12 @@ class Controller(object):
"notifications": BlueskiTimelines.NotificationBuffer,
"conversation": BlueskiTimelines.Conversation,
"likes": BlueskiTimelines.LikesBuffer,
"MentionsBuffer": BlueskiTimelines.MentionsBuffer,
"mentions": BlueskiTimelines.MentionsBuffer,
"SentBuffer": BlueskiTimelines.SentBuffer,
"sent": BlueskiTimelines.SentBuffer,
"SearchBuffer": BlueskiTimelines.SearchBuffer,
"search": BlueskiTimelines.SearchBuffer,
"UserBuffer": BlueskiUsers.UserBuffer,
"FollowersBuffer": BlueskiUsers.FollowersBuffer,
"FollowingBuffer": BlueskiUsers.FollowingBuffer,
@@ -757,10 +766,10 @@ class Controller(object):
dlg.Destroy()
if not text:
return
try:
uri = session.send_message(text, reply_to=selected_item_uri, reply_to_cid=selected_item_cid)
if uri:
output.speak(_("Reply sent."), True)
try:
uri = session.send_message(text, reply_to=selected_item_uri, reply_to_cid=selected_item_cid)
if uri:
output.speak(_("Reply sent."), True)
else:
output.speak(_("Failed to send reply."), True)
except Exception:
@@ -902,29 +911,13 @@ class Controller(object):
buffer = self.get_current_buffer()
if hasattr(buffer, "add_to_favorites"): # Generic buffer method
return buffer.add_to_favorites()
elif hasattr(buffer, "toggle_favorite"):
return buffer.toggle_favorite()
elif buffer.session and buffer.session.KIND == "blueski":
item_uri = buffer.get_selected_item_id()
if not item_uri:
output.speak(_("No item selected to like."), True)
return
social_handler = self.get_handler(buffer.session.KIND)
async def _like():
result = await social_handler.like_item(buffer.session, item_uri)
wx.CallAfter(output.speak, result["message"], True) # Ensure UI updates on main thread
if result.get("status") == "success" and result.get("like_uri"):
if hasattr(buffer, "store_item_viewer_state"):
# Ensure store_item_viewer_state is called on main thread if it modifies UI/shared data
wx.CallAfter(buffer.store_item_viewer_state, item_uri, "like_uri", result["like_uri"])
# Also update the item in message_cache to reflect the like
if buffer.session and hasattr(buffer.session, "message_cache") and item_uri in buffer.session.message_cache:
cached_post = buffer.session.message_cache[item_uri]
if isinstance(cached_post, dict) and isinstance(cached_post.get("viewer"), dict):
cached_post["viewer"]["like"] = result["like_uri"]
elif hasattr(cached_post, "viewer") and cached_post.viewer: # SDK model
cached_post.viewer.like = result["like_uri"]
# No need to call buffer.update_item here unless it re-renders from scratch
# The visual feedback might come from a list refresh or specific item update later
asyncio.create_task(_like()) # wx.CallAfter for the task itself if _like might interact with UI before await
# Fallback if buffer doesn't have the method but session is blueski (e.g. ChatBuffer)
# Chat messages can't be liked yet in this implementation, or handled by specific buffer
output.speak(_("This item cannot be liked."), True)
return
def remove_from_favourites(self, *args, **kwargs):
@@ -1475,7 +1468,12 @@ class Controller(object):
output.speak(_(u"Updating buffer..."), True)
session = bf.session
async def do_update():
output.speak(_(u"Updating buffer..."), True)
session = bf.session
import threading
def do_update_sync():
new_ids = []
try:
if session.KIND == "blueski":
@@ -1483,26 +1481,34 @@ class Controller(object):
count = bf.start_stream(mandatory=True)
if count: new_ids = [str(x) for x in range(count)]
else:
output.speak(_(u"This buffer type cannot be updated."), True)
wx.CallAfter(output.speak, _(u"This buffer type cannot be updated."), True)
return
else: # Generic fallback for other sessions
# If they are async, this might be tricky in a thread without a loop
# But most old sessions in TWBlue are sync (using threads)
if hasattr(bf, "start_stream"):
count = bf.start_stream(mandatory=True, avoid_autoreading=True)
if count: new_ids = [str(x) for x in range(count)]
else:
output.speak(_(u"Unable to update this buffer."), True)
wx.CallAfter(output.speak, _(u"Unable to update this buffer."), True)
return
# Generic feedback
if bf.type in ["home_timeline", "user_timeline"]:
output.speak(_("{0} posts retrieved").format(len(new_ids)), True)
elif bf.type == "notifications":
output.speak(_("Notifications updated."), True)
if bf.type in ["home_timeline", "user_timeline", "notifications", "mentions"]:
wx.CallAfter(output.speak, _("{0} new items.").format(len(new_ids)), True)
except Exception as e:
log.exception("Error updating buffer %s", bf.name)
output.speak(_("An error occurred while updating the buffer."), True)
wx.CallAfter(asyncio.create_task, do_update())
wx.CallAfter(output.speak, _("An error occurred while updating the buffer."), True)
if session.KIND == "blueski":
threading.Thread(target=do_update_sync).start()
else:
# Original async logic for others if needed, but likely they are sync too.
# Assuming TWBlue architecture is mostly thread-based for legacy sessions.
# If we have an async loop running, we could use it for async-capable sessions.
# For safety, let's use the thread approach generally if we are not sure about the loop state.
threading.Thread(target=do_update_sync).start()
def get_more_items(self, *args, **kwargs):
@@ -1637,6 +1643,33 @@ class Controller(object):
# if "direct_messages" not in buffer.session.settings["other_buffers"]["muted_buffers"]:
# self.notify(buffer.session, sound_to_play)
def blueski_new_item(self, item, session_name, _buffers):
"""Handle new items from Bluesky polling."""
sound_to_play = None
for buff in _buffers:
buffer = self.search_buffer(buff, session_name)
if buffer is None or buffer.session.get_name() != session_name:
continue
if hasattr(buffer, "add_new_item"):
buffer.add_new_item(item)
# Determine sound to play
if buff == "notifications":
sound_to_play = "notification_received.ogg"
elif buff == "home_timeline":
sound_to_play = "tweet_received.ogg"
elif "timeline" in buff:
sound_to_play = "tweet_timeline.ogg"
else:
sound_to_play = None
# Play sound if buffer is not muted
if sound_to_play is not None:
try:
muted = buffer.session.settings["other_buffers"].get("muted_buffers", [])
if buff not in muted:
self.notify(buffer.session, sound_to_play)
except Exception:
pass
def mastodon_error_post(self, name, reply_to, visibility, posts, language):
home = self.search_buffer("home_timeline", name)
if home != None:

View File

@@ -21,7 +21,7 @@ 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
# find remote users as Mastodon doesn't count the domain in char limit.
users = re.findall("@[\w\.-]+@[\w\.-]+", full_text)
users = re.findall(r"@[\w\.-]+@[\w\.-]+", full_text)
for user in users:
domain = user.split("@")[-1]
full_text = full_text.replace("@"+domain, "")

View File

@@ -20,7 +20,7 @@ class EditTemplate(object):
self.template: str = template
def validate_template(self, template: str) -> bool:
used_variables: List[str] = re.findall("\$\w+", template)
used_variables: List[str] = re.findall(r"\$\w+", template)
validated: bool = True
for var in used_variables:
if var[1:] not in self.variables: