mirror of
https://github.com/MCV-Software/TWBlue.git
synced 2026-08-17 18:38:11 +02:00
Merge branch 'next-gen' of github.com:mcv-software/twblue into next-gen
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
[blueski]
|
||||
handle = string(default="")
|
||||
app_password = string(default="")
|
||||
did = string(default="")
|
||||
session_string = string(default="")
|
||||
user_name = string(default="")
|
||||
|
||||
[general]
|
||||
relative_times = boolean(default=True)
|
||||
max_posts_per_call = integer(default=40)
|
||||
reverse_timelines = boolean(default=False)
|
||||
persist_size = integer(default=0)
|
||||
load_cache_in_memory = boolean(default=True)
|
||||
show_screen_names = boolean(default=False)
|
||||
hide_emojis = boolean(default=False)
|
||||
buffer_order = list(default=list('home', 'notifications'))
|
||||
disable_streaming = boolean(default=True)
|
||||
|
||||
[sound]
|
||||
volume = float(default=1.0)
|
||||
input_device = string(default="Default")
|
||||
output_device = string(default="Default")
|
||||
session_mute = boolean(default=False)
|
||||
current_soundpack = string(default="FreakyBlue")
|
||||
indicate_audio = boolean(default=True)
|
||||
indicate_img = boolean(default=True)
|
||||
|
||||
[other_buffers]
|
||||
timelines = list(default=list())
|
||||
followers_timelines = list(default=list())
|
||||
following_timelines = list(default=list())
|
||||
searches = list(default=list())
|
||||
muted_buffers = list(default=list())
|
||||
autoread_buffers = list(default=list(notifications))
|
||||
|
||||
[mysc]
|
||||
spelling_language = string(default="")
|
||||
save_followers_in_autocompletion_db = boolean(default=False)
|
||||
save_friends_in_autocompletion_db = boolean(default=False)
|
||||
ocr_language = string(default="")
|
||||
|
||||
[reporting]
|
||||
braille_reporting = boolean(default=True)
|
||||
speech_reporting = boolean(default=True)
|
||||
|
||||
[templates]
|
||||
post = string(default="$display_name, $reply_to$safe_text $date.")
|
||||
person = string(default="$display_name (@$screen_name). $followers followers, $following following, $posts posts. Joined $created_at.")
|
||||
notification = string(default="$display_name $text, $date")
|
||||
|
||||
[filters]
|
||||
|
||||
[user-aliases]
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
from .handler import Handler
|
||||
|
||||
__all__ = ["Handler"]
|
||||
@@ -0,0 +1,973 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import wx
|
||||
import asyncio
|
||||
import output
|
||||
from mysc.thread_utils import call_threaded
|
||||
import widgetUtils
|
||||
from extra.autocompletionUsers import completion
|
||||
from wxUI.dialogs.blueski.showUserProfile import ShowUserProfileDialog
|
||||
from typing import Any
|
||||
import languageHandler # Ensure _() injection
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Handler:
|
||||
"""Handler for Bluesky integration: creates minimal buffers."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.menus = dict(
|
||||
# Application menu
|
||||
updateProfile="HIDE",
|
||||
menuitem_search=_("&Search"),
|
||||
lists="HIDE",
|
||||
manageAliases="HIDE",
|
||||
# Item menu
|
||||
compose=_("&Post"),
|
||||
reply=_("Re&ply"),
|
||||
share=_("&Boost"),
|
||||
fav=_("&Add to favorites"),
|
||||
unfav="HIDE",
|
||||
view=_("&Show post"),
|
||||
view_conversation=_("View conversa&tion"),
|
||||
ocr=_("&OCR"),
|
||||
delete=_("&Delete"),
|
||||
# User menu
|
||||
follow=_("&Actions..."),
|
||||
timeline=_("&View timeline..."),
|
||||
dm=_("Direct me&ssage"),
|
||||
addAlias="HIDE",
|
||||
addToList="HIDE",
|
||||
removeFromList="HIDE",
|
||||
details=_("S&how user profile"),
|
||||
favs="HIDE",
|
||||
# Buffer menu
|
||||
community_timeline="HIDE",
|
||||
filter="HIDE",
|
||||
manage_filters="HIDE",
|
||||
)
|
||||
self.item_menu = _("&Post")
|
||||
|
||||
def create_buffers(self, session, createAccounts=True, controller=None):
|
||||
name = session.get_name()
|
||||
if createAccounts:
|
||||
from pubsub import pub
|
||||
pub.sendMessage("core.create_account", name=name, session_id=session.session_id, logged=session.logged)
|
||||
|
||||
if not session.logged:
|
||||
logger.debug(f"Session {session.session_id} is not logged in, skipping timeline buffer creation.")
|
||||
return
|
||||
if name not in controller.accounts:
|
||||
controller.accounts.append(name)
|
||||
|
||||
root_position = controller.view.search(name, name)
|
||||
from pubsub import pub
|
||||
# Home (Following-only timeline - reverse-chronological)
|
||||
pub.sendMessage(
|
||||
"createBuffer",
|
||||
buffer_type="following_timeline",
|
||||
session_type="blueski",
|
||||
buffer_title=_("Home"),
|
||||
parent_tab=root_position,
|
||||
start=True,
|
||||
kwargs=dict(parent=controller.view.nb, name="following_timeline", session=session, sound="tweet_received.ogg")
|
||||
)
|
||||
# Discover timeline
|
||||
pub.sendMessage(
|
||||
"createBuffer",
|
||||
buffer_type="home_timeline",
|
||||
session_type="blueski",
|
||||
buffer_title=_("Discover"),
|
||||
parent_tab=root_position,
|
||||
start=False,
|
||||
kwargs=dict(parent=controller.view.nb, name="home_timeline", session=session, sound="tweet_received.ogg")
|
||||
)
|
||||
# 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, sound="mention_received.ogg")
|
||||
)
|
||||
# Chats
|
||||
pub.sendMessage(
|
||||
"createBuffer",
|
||||
buffer_type="ConversationListBuffer",
|
||||
session_type="blueski",
|
||||
buffer_title=_("Chats"),
|
||||
parent_tab=root_position,
|
||||
start=False,
|
||||
kwargs=dict(parent=controller.view.nb, name="direct_messages", session=session, sound="dm_received.ogg")
|
||||
)
|
||||
# Notifications
|
||||
pub.sendMessage(
|
||||
"createBuffer",
|
||||
buffer_type="notifications",
|
||||
session_type="blueski",
|
||||
buffer_title=_("Notifications"),
|
||||
parent_tab=root_position,
|
||||
start=False,
|
||||
kwargs=dict(parent=controller.view.nb, name="notifications", session=session, sound="new_event.ogg")
|
||||
)
|
||||
# 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, sound="tweet_received.ogg")
|
||||
)
|
||||
# Likes
|
||||
pub.sendMessage(
|
||||
"createBuffer",
|
||||
buffer_type="likes",
|
||||
session_type="blueski",
|
||||
buffer_title=_("Likes"),
|
||||
parent_tab=root_position,
|
||||
start=False,
|
||||
kwargs=dict(parent=controller.view.nb, name="likes", session=session, sound="favourite.ogg")
|
||||
)
|
||||
# Followers
|
||||
pub.sendMessage(
|
||||
"createBuffer",
|
||||
buffer_type="FollowersBuffer",
|
||||
session_type="blueski",
|
||||
buffer_title=_("Followers"),
|
||||
parent_tab=root_position,
|
||||
start=False,
|
||||
kwargs=dict(parent=controller.view.nb, name="followers", session=session, sound="update_followers.ogg")
|
||||
)
|
||||
# Followings (Users you follow)
|
||||
pub.sendMessage(
|
||||
"createBuffer",
|
||||
buffer_type="FollowingBuffer",
|
||||
session_type="blueski",
|
||||
buffer_title=_("Following"),
|
||||
parent_tab=root_position,
|
||||
start=False,
|
||||
kwargs=dict(parent=controller.view.nb, name="following", session=session, sound="update_followers.ogg")
|
||||
)
|
||||
# Blocks
|
||||
pub.sendMessage(
|
||||
"createBuffer",
|
||||
buffer_type="BlocksBuffer",
|
||||
session_type="blueski",
|
||||
buffer_title=_("Blocked Users"),
|
||||
parent_tab=root_position,
|
||||
start=False,
|
||||
kwargs=dict(parent=controller.view.nb, name="blocked", session=session)
|
||||
)
|
||||
|
||||
# Timelines container
|
||||
pub.sendMessage(
|
||||
"createBuffer",
|
||||
buffer_type="EmptyBuffer",
|
||||
session_type="base",
|
||||
buffer_title=_("Timelines"),
|
||||
parent_tab=root_position,
|
||||
start=False,
|
||||
kwargs=dict(parent=controller.view.nb, name="timelines", account=name)
|
||||
)
|
||||
timelines_position = controller.view.search("timelines", name)
|
||||
|
||||
# Searches container (Bluesky supports search buffers)
|
||||
pub.sendMessage(
|
||||
"createBuffer",
|
||||
buffer_type="EmptyBuffer",
|
||||
session_type="base",
|
||||
buffer_title=_("Searches"),
|
||||
parent_tab=root_position,
|
||||
start=False,
|
||||
kwargs=dict(parent=controller.view.nb, name="searches", account=name)
|
||||
)
|
||||
searches_position = controller.view.search("searches", name)
|
||||
|
||||
# Saved searches
|
||||
try:
|
||||
searches = session.settings["other_buffers"].get("searches")
|
||||
if searches is None:
|
||||
searches = []
|
||||
if isinstance(searches, str):
|
||||
searches = [s for s in searches.split(",") if s]
|
||||
for query in searches:
|
||||
buffer_name = f"search_{query[:20]}"
|
||||
title = _("Search: {query}").format(query=query)
|
||||
pub.sendMessage(
|
||||
"createBuffer",
|
||||
buffer_type="SearchBuffer",
|
||||
session_type="blueski",
|
||||
buffer_title=title,
|
||||
parent_tab=searches_position,
|
||||
start=False,
|
||||
kwargs=dict(parent=controller.view.nb, name=buffer_name, session=session, query=query, sound="search_updated.ogg")
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Failed to restore Bluesky search buffers: %s", e)
|
||||
|
||||
# Saved user timelines
|
||||
try:
|
||||
timelines = session.settings["other_buffers"].get("timelines")
|
||||
if timelines is None:
|
||||
timelines = []
|
||||
if isinstance(timelines, str):
|
||||
timelines = [t for t in timelines.split(",") if t]
|
||||
for actor in timelines:
|
||||
handle = actor
|
||||
try:
|
||||
if isinstance(actor, str) and actor.startswith("did:"):
|
||||
profile = session.get_profile(actor)
|
||||
if profile:
|
||||
def g(obj, key, default=None):
|
||||
if isinstance(obj, dict):
|
||||
return obj.get(key, default)
|
||||
return getattr(obj, key, default)
|
||||
handle = g(profile, "handle") or actor
|
||||
except Exception:
|
||||
handle = actor
|
||||
title = _("Timeline for {user}").format(user=handle)
|
||||
pub.sendMessage(
|
||||
"createBuffer",
|
||||
buffer_type="UserTimeline",
|
||||
session_type="blueski",
|
||||
buffer_title=title,
|
||||
parent_tab=timelines_position,
|
||||
start=False,
|
||||
kwargs=dict(parent=controller.view.nb, name=f"{handle}-timeline", session=session, actor=actor, handle=handle, sound="tweet_timeline.ogg")
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Failed to restore Bluesky timeline buffers: %s", e)
|
||||
|
||||
# Saved followers/following timelines
|
||||
try:
|
||||
followers = session.settings["other_buffers"].get("followers_timelines")
|
||||
if followers is None:
|
||||
followers = []
|
||||
if isinstance(followers, str):
|
||||
followers = [t for t in followers.split(",") if t]
|
||||
for actor in followers:
|
||||
handle = actor
|
||||
try:
|
||||
if isinstance(actor, str) and actor.startswith("did:"):
|
||||
profile = session.get_profile(actor)
|
||||
if profile:
|
||||
def g(obj, key, default=None):
|
||||
if isinstance(obj, dict):
|
||||
return obj.get(key, default)
|
||||
return getattr(obj, key, default)
|
||||
handle = g(profile, "handle") or actor
|
||||
except Exception:
|
||||
handle = actor
|
||||
own_actor = session.db.get("user_id") or session.db.get("user_name")
|
||||
own_handle = session.db.get("user_name")
|
||||
if actor == own_actor or (own_handle and actor == own_handle) or (handle and own_handle and handle == own_handle):
|
||||
continue
|
||||
title = _("Followers for {user}").format(user=handle)
|
||||
pub.sendMessage(
|
||||
"createBuffer",
|
||||
buffer_type="FollowersBuffer",
|
||||
session_type="blueski",
|
||||
buffer_title=title,
|
||||
parent_tab=timelines_position,
|
||||
start=False,
|
||||
kwargs=dict(parent=controller.view.nb, name=f"{handle}-followers", session=session, actor=actor, handle=handle, sound="new_event.ogg")
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Failed to restore Bluesky followers buffers: %s", e)
|
||||
|
||||
try:
|
||||
following = session.settings["other_buffers"].get("following_timelines")
|
||||
if following is None:
|
||||
following = []
|
||||
if isinstance(following, str):
|
||||
following = [t for t in following.split(",") if t]
|
||||
for actor in following:
|
||||
handle = actor
|
||||
try:
|
||||
if isinstance(actor, str) and actor.startswith("did:"):
|
||||
profile = session.get_profile(actor)
|
||||
if profile:
|
||||
def g(obj, key, default=None):
|
||||
if isinstance(obj, dict):
|
||||
return obj.get(key, default)
|
||||
return getattr(obj, key, default)
|
||||
handle = g(profile, "handle") or actor
|
||||
except Exception:
|
||||
handle = actor
|
||||
own_actor = session.db.get("user_id") or session.db.get("user_name")
|
||||
own_handle = session.db.get("user_name")
|
||||
if actor == own_actor or (own_handle and actor == own_handle) or (handle and own_handle and handle == own_handle):
|
||||
continue
|
||||
title = _("Following for {user}").format(user=handle)
|
||||
pub.sendMessage(
|
||||
"createBuffer",
|
||||
buffer_type="FollowingBuffer",
|
||||
session_type="blueski",
|
||||
buffer_title=title,
|
||||
parent_tab=timelines_position,
|
||||
start=False,
|
||||
kwargs=dict(parent=controller.view.nb, name=f"{handle}-following", session=session, actor=actor, handle=handle, sound="new_event.ogg")
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Failed to restore Bluesky following buffers: %s", e)
|
||||
|
||||
# Start the background poller for real-time-like updates
|
||||
try:
|
||||
session.start_streaming()
|
||||
except Exception as e:
|
||||
logger.error("Failed to start Bluesky streaming for session %s: %s", name, e)
|
||||
|
||||
def start_buffer(self, controller, buffer):
|
||||
"""Start a newly created Bluesky buffer."""
|
||||
try:
|
||||
if hasattr(buffer, "start_stream"):
|
||||
buffer.start_stream(mandatory=True, play_sound=False)
|
||||
# Enable periodic auto-refresh to simulate real-time updates
|
||||
if hasattr(buffer, "enable_auto_refresh"):
|
||||
buffer.enable_auto_refresh()
|
||||
finally:
|
||||
# Ensure we won't try to start it again
|
||||
try:
|
||||
buffer.needs_init = False
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def account_settings(self, buffer, controller):
|
||||
"""Open a minimal account settings dialog for Bluesky."""
|
||||
try:
|
||||
current_mode = None
|
||||
try:
|
||||
current_mode = buffer.session.settings["general"].get("boost_mode")
|
||||
except Exception:
|
||||
current_mode = None
|
||||
ask_default = True if current_mode in (None, "ask") else False
|
||||
|
||||
from wxUI.dialogs.blueski.configuration import AccountSettingsDialog
|
||||
from .templateEditor import EditTemplate
|
||||
dlg = AccountSettingsDialog(controller.view, ask_before_boost=ask_default)
|
||||
try:
|
||||
if buffer.session.settings.get("templates") is None:
|
||||
buffer.session.settings["templates"] = {}
|
||||
templates_cfg = buffer.session.settings.get("templates", {})
|
||||
template_state = {
|
||||
"post": templates_cfg.get("post", "$display_name, $reply_to$safe_text $date."),
|
||||
"person": templates_cfg.get("person", "$display_name (@$screen_name). $followers followers, $following following, $posts posts. Joined $created_at."),
|
||||
"notification": templates_cfg.get("notification", "$display_name $text, $date"),
|
||||
}
|
||||
dlg.set_template_labels(template_state["post"], template_state["person"], template_state["notification"])
|
||||
|
||||
def edit_post_template(*args, **kwargs):
|
||||
control = EditTemplate(template=template_state["post"], type="post")
|
||||
result = control.run_dialog()
|
||||
if result:
|
||||
buffer.session.settings["templates"]["post"] = result
|
||||
buffer.session.settings.write()
|
||||
template_state["post"] = result
|
||||
dlg.set_template_labels(template_state["post"], template_state["person"], template_state["notification"])
|
||||
|
||||
def edit_person_template(*args, **kwargs):
|
||||
control = EditTemplate(template=template_state["person"], type="person")
|
||||
result = control.run_dialog()
|
||||
if result:
|
||||
buffer.session.settings["templates"]["person"] = result
|
||||
buffer.session.settings.write()
|
||||
template_state["person"] = result
|
||||
dlg.set_template_labels(template_state["post"], template_state["person"], template_state["notification"])
|
||||
|
||||
def edit_notification_template(*args, **kwargs):
|
||||
control = EditTemplate(template=template_state["notification"], type="notification")
|
||||
result = control.run_dialog()
|
||||
if result:
|
||||
buffer.session.settings["templates"]["notification"] = result
|
||||
buffer.session.settings.write()
|
||||
template_state["notification"] = result
|
||||
dlg.set_template_labels(template_state["post"], template_state["person"], template_state["notification"])
|
||||
|
||||
widgetUtils.connect_event(dlg.template_post, widgetUtils.BUTTON_PRESSED, edit_post_template)
|
||||
widgetUtils.connect_event(dlg.template_person, widgetUtils.BUTTON_PRESSED, edit_person_template)
|
||||
widgetUtils.connect_event(dlg.template_notification, widgetUtils.BUTTON_PRESSED, edit_notification_template)
|
||||
except Exception as e:
|
||||
logger.error("Failed to init Bluesky templates editor: %s", e)
|
||||
resp = dlg.ShowModal()
|
||||
if resp == wx.ID_OK:
|
||||
vals = dlg.get_values()
|
||||
boost_mode = "ask" if vals.get("ask_before_boost") else "direct"
|
||||
try:
|
||||
buffer.session.settings["general"]["boost_mode"] = boost_mode
|
||||
buffer.session.settings.write()
|
||||
except Exception as e:
|
||||
logger.error("Failed to persist Bluesky boost_mode setting: %s", e)
|
||||
dlg.Destroy()
|
||||
except Exception as e:
|
||||
logger.error("Error opening Bluesky account settings dialog: %s", e)
|
||||
|
||||
def user_details(self, buffer):
|
||||
"""Show user profile dialog for the selected user/post."""
|
||||
session = getattr(buffer, "session", None)
|
||||
if not session:
|
||||
output.speak(_("No active session to view user details."), True)
|
||||
return
|
||||
|
||||
item = buffer.get_item() if hasattr(buffer, "get_item") else None
|
||||
if not item:
|
||||
output.speak(_("No user selected or identified to view details."), True)
|
||||
return
|
||||
|
||||
def g(obj, key, default=None):
|
||||
if isinstance(obj, dict):
|
||||
return obj.get(key, default)
|
||||
return getattr(obj, key, default)
|
||||
|
||||
user_ident = None
|
||||
|
||||
# If we're in a user list, the item itself is the user profile dict/model.
|
||||
if g(item, "did") or g(item, "handle"):
|
||||
user_ident = g(item, "did") or g(item, "handle")
|
||||
else:
|
||||
author = g(item, "author")
|
||||
if not author:
|
||||
post = g(item, "post") or g(item, "record")
|
||||
author = g(post, "author") if post else None
|
||||
if author:
|
||||
user_ident = g(author, "did") or g(author, "handle")
|
||||
|
||||
if not user_ident:
|
||||
output.speak(_("No user selected or identified to view details."), True)
|
||||
return
|
||||
|
||||
parent = getattr(buffer, "buffer", None) or wx.GetApp().GetTopWindow()
|
||||
dialog = ShowUserProfileDialog(parent, session, user_ident)
|
||||
dialog.ShowModal()
|
||||
dialog.Destroy()
|
||||
|
||||
async def handle_action(self, action_name: str, user_id: str, payload: dict[str, Any]) -> dict[str, Any] | None:
|
||||
logger.debug("handle_action stub: %s %s %s", action_name, user_id, payload)
|
||||
return None
|
||||
|
||||
async def handle_message_command(self, command: str, user_id: str, message_id: str, payload: dict[str, Any]) -> dict[str, Any] | None:
|
||||
logger.debug("handle_message_command stub: %s %s %s %s", command, user_id, message_id, payload)
|
||||
return None
|
||||
|
||||
async def handle_user_command(self, command: str, user_id: str, target_user_id: str, payload: dict[str, Any]) -> dict[str, Any] | None:
|
||||
logger.debug("handle_user_command stub: %s %s %s %s", command, user_id, target_user_id, payload)
|
||||
return None
|
||||
|
||||
def add_to_favourites(self, buffer):
|
||||
"""Standard action for Alt+Win+F"""
|
||||
if hasattr(buffer, "add_to_favorites"):
|
||||
buffer.add_to_favorites()
|
||||
elif hasattr(buffer, "on_like"):
|
||||
# Fallback
|
||||
buffer.on_like(None)
|
||||
|
||||
def remove_from_favourites(self, buffer):
|
||||
"""Standard action for Alt+Shift+Win+F"""
|
||||
if hasattr(buffer, "remove_from_favorites"):
|
||||
buffer.remove_from_favorites()
|
||||
elif hasattr(buffer, "on_like"):
|
||||
buffer.on_like(None)
|
||||
|
||||
def follow(self, buffer):
|
||||
"""Standard action for Ctrl+Win+S - Opens user actions dialog"""
|
||||
if not hasattr(buffer, "get_item"):
|
||||
return
|
||||
session = getattr(buffer, "session", None)
|
||||
if not session:
|
||||
output.speak(_("No active session."), True)
|
||||
return
|
||||
|
||||
item = buffer.get_item()
|
||||
if not item:
|
||||
return
|
||||
|
||||
def g(obj, key, default=None):
|
||||
if isinstance(obj, dict):
|
||||
return obj.get(key, default)
|
||||
return getattr(obj, key, default)
|
||||
|
||||
users = []
|
||||
buffer_type = getattr(buffer, "type", "")
|
||||
|
||||
if buffer_type in ("user", "post_user_list"):
|
||||
# User buffer - item is a user object
|
||||
handle = g(item, "handle")
|
||||
if handle:
|
||||
users = [handle]
|
||||
elif buffer_type == "notifications":
|
||||
# Notification buffer
|
||||
author = g(item, "author")
|
||||
if author:
|
||||
handle = g(author, "handle")
|
||||
if handle:
|
||||
users.append(handle)
|
||||
# Also check for post author in the notification subject
|
||||
record = g(item, "record")
|
||||
if record:
|
||||
subject = g(record, "subject")
|
||||
if subject:
|
||||
subject_author = g(subject, "author")
|
||||
if subject_author:
|
||||
subject_handle = g(subject_author, "handle")
|
||||
if subject_handle and subject_handle not in users:
|
||||
users.append(subject_handle)
|
||||
else:
|
||||
# Post buffer - extract author and mentioned users
|
||||
# Get the actual post (could be nested in "post" key)
|
||||
actual_post = g(item, "post", item)
|
||||
record = g(actual_post, "record") or {}
|
||||
|
||||
# Extract mentions from facets
|
||||
facets = g(record, "facets") or []
|
||||
for facet in facets:
|
||||
features = g(facet, "features") or []
|
||||
for feature in features:
|
||||
ftype = g(feature, "$type") or g(feature, "py_type") or ""
|
||||
if "mention" in ftype.lower():
|
||||
mention_did = g(feature, "did")
|
||||
# We'd need to resolve DID to handle, but for simplicity just skip
|
||||
# The main author will be added below
|
||||
|
||||
# Get the post author
|
||||
author = g(actual_post, "author") or g(item, "author")
|
||||
if author:
|
||||
handle = g(author, "handle")
|
||||
if handle and handle not in users:
|
||||
users.insert(0, handle)
|
||||
|
||||
# Ensure we have at least the author if no users found
|
||||
if not users:
|
||||
author = g(item, "author") or g(g(item, "post"), "author")
|
||||
if author:
|
||||
handle = g(author, "handle")
|
||||
if handle:
|
||||
users = [handle]
|
||||
|
||||
from controller.blueski import userActions as user_actions_controller
|
||||
user_actions_controller.userActions(session, users)
|
||||
|
||||
def open_conversation(self, controller, buffer):
|
||||
"""Standard action for Control+Win+C"""
|
||||
# If this is a chat conversation list, open the selected chat
|
||||
if buffer.type == "chat" and hasattr(buffer, "view_chat"):
|
||||
buffer.view_chat()
|
||||
return
|
||||
item = buffer.get_item()
|
||||
if not item:
|
||||
return
|
||||
|
||||
uri = None
|
||||
if hasattr(buffer, "get_selected_item_id"):
|
||||
uri = buffer.get_selected_item_id()
|
||||
if not uri:
|
||||
uri = getattr(item, "uri", None) or (item.get("post", {}).get("uri") if isinstance(item, dict) else None)
|
||||
if not uri: return
|
||||
|
||||
# Buffer Title
|
||||
handle = None
|
||||
display_name = None
|
||||
if hasattr(buffer, "get_selected_item_author_details"):
|
||||
details = buffer.get_selected_item_author_details()
|
||||
if details:
|
||||
handle = details.get("handle")
|
||||
if not handle:
|
||||
def g(obj, key, default=None):
|
||||
if isinstance(obj, dict):
|
||||
return obj.get(key, default)
|
||||
return getattr(obj, key, default)
|
||||
author = g(item, "author") or g(g(item, "post"), "author")
|
||||
if author:
|
||||
handle = g(author, "handle")
|
||||
display_name = g(author, "displayName") or g(author, "display_name")
|
||||
label = handle or display_name or _("Unknown")
|
||||
title = _("Conversation with {0}").format(label)
|
||||
|
||||
from pubsub import pub
|
||||
pub.sendMessage(
|
||||
"createBuffer",
|
||||
buffer_type="conversation",
|
||||
session_type="blueski",
|
||||
buffer_title=title,
|
||||
parent_tab=controller.view.search(buffer.session.get_name(), buffer.session.get_name()) if hasattr(buffer.session, "get_name") else None,
|
||||
start=True,
|
||||
kwargs=dict(parent=controller.view.nb, name=title, session=buffer.session, uri=uri, sound="search_updated.ogg")
|
||||
)
|
||||
|
||||
def open_timeline(self, controller, buffer, default="posts"):
|
||||
if not hasattr(buffer, "get_item"):
|
||||
return
|
||||
item = buffer.get_item()
|
||||
if not item:
|
||||
output.speak(_("No user selected."), True)
|
||||
return
|
||||
|
||||
def g(obj, key, default=None):
|
||||
if isinstance(obj, dict):
|
||||
return obj.get(key, default)
|
||||
return getattr(obj, key, default)
|
||||
|
||||
users = []
|
||||
handle = None
|
||||
if hasattr(buffer, "get_selected_item_author_details"):
|
||||
details = buffer.get_selected_item_author_details()
|
||||
if details:
|
||||
handle = details.get("handle") or details.get("did")
|
||||
if not handle:
|
||||
if g(item, "handle") or g(item, "did"):
|
||||
handle = g(item, "handle") or g(item, "did")
|
||||
else:
|
||||
author = g(item, "author") or g(g(item, "post"), "author")
|
||||
if author:
|
||||
handle = g(author, "handle") or g(author, "did")
|
||||
|
||||
if not handle:
|
||||
output.speak(_("No user selected."), True)
|
||||
return
|
||||
users.append(handle)
|
||||
|
||||
# Add mentioned users if available (facets)
|
||||
record = g(g(item, "post"), "record") or g(item, "record")
|
||||
facets = g(record, "facets", []) if record else []
|
||||
handle_cache = {}
|
||||
|
||||
def resolve_handle(did):
|
||||
if did in handle_cache:
|
||||
return handle_cache[did]
|
||||
try:
|
||||
profile = buffer.session.get_profile(did)
|
||||
if profile:
|
||||
h = g(profile, "handle")
|
||||
if h:
|
||||
handle_cache[did] = h
|
||||
return h
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
self_did = buffer.session.db.get("user_id")
|
||||
for facet in facets or []:
|
||||
features = g(facet, "features", []) or []
|
||||
for feat in features:
|
||||
ftype = g(feat, "$type") or g(feat, "py_type") or ""
|
||||
if "facet#mention" in ftype:
|
||||
did = g(feat, "did")
|
||||
if not did or did == self_did:
|
||||
continue
|
||||
h = resolve_handle(did)
|
||||
if h and h not in users:
|
||||
users.append(h)
|
||||
|
||||
from wxUI.dialogs.mastodon import userTimeline as userTimelineDialog
|
||||
dlg = userTimelineDialog.UserTimeline(users=users, default=default)
|
||||
try:
|
||||
widgetUtils.connect_event(
|
||||
dlg.autocompletion,
|
||||
widgetUtils.BUTTON_PRESSED,
|
||||
lambda *args, **kwargs: completion.autocompletionUsers(dlg, buffer.session.session_id).show_menu("free"),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
if hasattr(dlg, "autocompletion"):
|
||||
dlg.autocompletion.Enable(True)
|
||||
except Exception:
|
||||
pass
|
||||
if dlg.ShowModal() != wx.ID_OK:
|
||||
dlg.Destroy()
|
||||
return
|
||||
|
||||
action = dlg.get_action()
|
||||
user = dlg.get_user().strip() or handle
|
||||
dlg.Destroy()
|
||||
|
||||
if user.startswith("@"):
|
||||
user = user[1:]
|
||||
try:
|
||||
profile = buffer.session.get_profile(user)
|
||||
if profile is None:
|
||||
output.speak(_("User not found."), True)
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
user_payload = {"handle": user}
|
||||
if action == "posts":
|
||||
result = self.open_user_timeline(main_controller=controller, session=buffer.session, user_payload=user_payload)
|
||||
elif action == "followers":
|
||||
result = self.open_followers_timeline(main_controller=controller, session=buffer.session, user_payload=user_payload)
|
||||
elif action == "following":
|
||||
result = self.open_following_timeline(main_controller=controller, session=buffer.session, user_payload=user_payload)
|
||||
else:
|
||||
return
|
||||
|
||||
if asyncio.iscoroutine(result):
|
||||
call_threaded(asyncio.run, result)
|
||||
|
||||
def open_followers_timeline(self, main_controller, session, user_payload=None):
|
||||
actor, handle = self._resolve_actor(session, user_payload)
|
||||
if not actor:
|
||||
output.speak(_("No user selected."), True)
|
||||
return
|
||||
self._open_user_list(main_controller, session, actor, handle, list_type="followers")
|
||||
|
||||
def open_following_timeline(self, main_controller, session, user_payload=None):
|
||||
actor, handle = self._resolve_actor(session, user_payload)
|
||||
if not actor:
|
||||
output.speak(_("No user selected."), True)
|
||||
return
|
||||
self._open_user_list(main_controller, session, actor, handle, list_type="following")
|
||||
|
||||
def open_user_timeline(self, main_controller, session, user_payload=None):
|
||||
"""Open posts timeline for a user (Alt+Win+I)."""
|
||||
actor, handle = self._resolve_actor(session, user_payload)
|
||||
if not actor:
|
||||
output.speak(_("No user selected."), True)
|
||||
return
|
||||
|
||||
actor, handle = self._resolve_actor(session, {"did": actor, "handle": handle})
|
||||
if not handle:
|
||||
handle = actor
|
||||
|
||||
account_name = session.get_name()
|
||||
list_name = f"{handle}-timeline"
|
||||
if main_controller.search_buffer(list_name, account_name):
|
||||
index = main_controller.view.search(list_name, account_name)
|
||||
if index is not None:
|
||||
main_controller.view.change_buffer(index)
|
||||
return
|
||||
|
||||
title = _("Timeline for {user}").format(user=handle)
|
||||
from pubsub import pub
|
||||
pub.sendMessage(
|
||||
"createBuffer",
|
||||
buffer_type="UserTimeline",
|
||||
session_type="blueski",
|
||||
buffer_title=title,
|
||||
parent_tab=main_controller.view.search("timelines", account_name),
|
||||
start=True,
|
||||
kwargs=dict(parent=main_controller.view.nb, name=list_name, session=session, actor=actor, handle=handle, sound="tweet_timeline.ogg")
|
||||
)
|
||||
try:
|
||||
timelines = session.settings["other_buffers"].get("timelines")
|
||||
if timelines is None:
|
||||
timelines = []
|
||||
if isinstance(timelines, str):
|
||||
timelines = [t for t in timelines.split(",") if t]
|
||||
key = actor or handle
|
||||
if key in timelines:
|
||||
from wxUI import commonMessageDialogs
|
||||
commonMessageDialogs.timeline_exist()
|
||||
return
|
||||
if key:
|
||||
timelines.append(key)
|
||||
session.settings["other_buffers"]["timelines"] = timelines
|
||||
session.settings.write()
|
||||
except Exception as e:
|
||||
logger.error("Failed to persist Bluesky timeline buffer: %s", e)
|
||||
|
||||
def _resolve_actor(self, session, user_payload):
|
||||
def g(obj, key, default=None):
|
||||
if isinstance(obj, dict):
|
||||
return obj.get(key, default)
|
||||
return getattr(obj, key, default)
|
||||
|
||||
actor = None
|
||||
handle = None
|
||||
if user_payload:
|
||||
actor = g(user_payload, "did") or g(user_payload, "handle")
|
||||
handle = g(user_payload, "handle") or g(user_payload, "did")
|
||||
if isinstance(actor, str):
|
||||
actor = actor.strip()
|
||||
if actor.startswith("@"):
|
||||
actor = actor[1:]
|
||||
if isinstance(handle, str):
|
||||
handle = handle.strip()
|
||||
if handle.startswith("@"):
|
||||
handle = handle[1:]
|
||||
# Resolve handle -> DID when possible, and keep handle for titles
|
||||
try:
|
||||
if isinstance(actor, str) and not actor.startswith("did:"):
|
||||
profile = session.get_profile(actor)
|
||||
if profile:
|
||||
did = g(profile, "did")
|
||||
if did:
|
||||
actor = did
|
||||
if not handle:
|
||||
handle = g(profile, "handle")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not actor:
|
||||
actor = session.db.get("user_id") or session.db.get("user_name")
|
||||
handle = session.db.get("user_name") or actor
|
||||
|
||||
if not handle and isinstance(actor, str):
|
||||
try:
|
||||
if actor.startswith("did:"):
|
||||
profile = session.get_profile(actor)
|
||||
if profile:
|
||||
handle = g(profile, "handle")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return actor, handle
|
||||
|
||||
def _open_user_list(self, main_controller, session, actor, handle, list_type):
|
||||
account_name = session.get_name()
|
||||
if not handle:
|
||||
handle = actor
|
||||
own_actor = session.db.get("user_id") or session.db.get("user_name")
|
||||
own_handle = session.db.get("user_name")
|
||||
if actor == own_actor or (own_handle and actor == own_handle) or (handle and own_handle and handle == own_handle):
|
||||
name = "followers" if list_type == "followers" else "following"
|
||||
try:
|
||||
stored = session.settings["other_buffers"].get("followers_timelines" if list_type == "followers" else "following_timelines") or []
|
||||
if isinstance(stored, str):
|
||||
stored = [t for t in stored.split(",") if t]
|
||||
if actor in stored:
|
||||
stored.remove(actor)
|
||||
session.settings["other_buffers"]["followers_timelines" if list_type == "followers" else "following_timelines"] = stored
|
||||
session.settings.write()
|
||||
except Exception:
|
||||
pass
|
||||
index = main_controller.view.search(name, account_name)
|
||||
if index is not None:
|
||||
main_controller.view.change_buffer(index)
|
||||
return
|
||||
list_name = f"{handle}-{list_type}"
|
||||
if main_controller.search_buffer(list_name, account_name):
|
||||
index = main_controller.view.search(list_name, account_name)
|
||||
if index is not None:
|
||||
main_controller.view.change_buffer(index)
|
||||
return
|
||||
|
||||
settings_key = "followers_timelines" if list_type == "followers" else "following_timelines"
|
||||
try:
|
||||
stored = session.settings["other_buffers"].get(settings_key)
|
||||
if stored is None:
|
||||
stored = []
|
||||
if isinstance(stored, str):
|
||||
stored = [t for t in stored.split(",") if t]
|
||||
key = actor or handle
|
||||
if key in stored:
|
||||
from wxUI import commonMessageDialogs
|
||||
commonMessageDialogs.timeline_exist()
|
||||
return
|
||||
except Exception:
|
||||
stored = None
|
||||
|
||||
title = _("Followers for {user}").format(user=handle) if list_type == "followers" else _("Following for {user}").format(user=handle)
|
||||
from pubsub import pub
|
||||
pub.sendMessage(
|
||||
"createBuffer",
|
||||
buffer_type="FollowersBuffer" if list_type == "followers" else "FollowingBuffer",
|
||||
session_type="blueski",
|
||||
buffer_title=title,
|
||||
parent_tab=main_controller.view.search("timelines", account_name),
|
||||
start=True,
|
||||
kwargs=dict(parent=main_controller.view.nb, name=list_name, session=session, actor=actor, handle=handle, sound="new_event.ogg")
|
||||
)
|
||||
try:
|
||||
if stored is None:
|
||||
stored = session.settings["other_buffers"].get(settings_key) or []
|
||||
if isinstance(stored, str):
|
||||
stored = [t for t in stored.split(",") if t]
|
||||
key = actor or handle
|
||||
if key:
|
||||
stored.append(key)
|
||||
session.settings["other_buffers"][settings_key] = stored
|
||||
session.settings.write()
|
||||
except Exception as e:
|
||||
logger.error("Failed to persist Bluesky %s buffer: %s", list_type, e)
|
||||
|
||||
def delete(self, buffer, controller):
|
||||
"""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):
|
||||
import output
|
||||
output.speak(_("Post deleted."))
|
||||
# Refresh buffer
|
||||
if hasattr(buffer, "start_stream"):
|
||||
buffer.start_stream(mandatory=True, play_sound=False)
|
||||
else:
|
||||
import output
|
||||
output.speak(_("Failed to delete post."))
|
||||
|
||||
def search(self, controller, session, value=""):
|
||||
"""Open search dialog and create search buffer for results."""
|
||||
dlg = wx.TextEntryDialog(
|
||||
controller.view,
|
||||
_("Enter search term:"),
|
||||
_("Search Bluesky"),
|
||||
value
|
||||
)
|
||||
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("searches", account_name),
|
||||
start=True,
|
||||
kwargs=dict(
|
||||
parent=controller.view.nb,
|
||||
name=buffer_name,
|
||||
session=session,
|
||||
query=query,
|
||||
sound="search_updated.ogg"
|
||||
)
|
||||
)
|
||||
|
||||
# Save search to settings for persistence
|
||||
try:
|
||||
searches = session.settings["other_buffers"].get("searches")
|
||||
if searches is None:
|
||||
searches = []
|
||||
if isinstance(searches, str):
|
||||
searches = [s for s in searches.split(",") if s]
|
||||
if query not in searches:
|
||||
searches.append(query)
|
||||
session.settings["other_buffers"]["searches"] = searches
|
||||
session.settings.write()
|
||||
except Exception as e:
|
||||
logger.error("Failed to save search to settings: %s", e)
|
||||
@@ -0,0 +1,433 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import arrow
|
||||
import languageHandler
|
||||
import output
|
||||
import widgetUtils
|
||||
from controller import messages as base_messages
|
||||
from sessions.blueski import utils as bluesky_utils
|
||||
from wxUI.dialogs.blueski import postDialogs
|
||||
from extra.autocompletionUsers import completion
|
||||
|
||||
# Translation function is provided globally by TWBlue's language handler (_)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# This file would typically contain functions to generate complex message bodies or
|
||||
# interactive components for Blueski, similar to how it might be done for Mastodon.
|
||||
# Since Blueski's interactive features (beyond basic posts) are still evolving
|
||||
# or client-dependent (like polls), this might be less complex initially.
|
||||
|
||||
# Example: If Blueski develops a standard for "cards" or interactive messages,
|
||||
# functions to create those would go here. For now, we can imagine placeholders.
|
||||
|
||||
def format_welcome_message(session: Any) -> dict[str, Any]:
|
||||
"""
|
||||
Generates a welcome message for a new Blueski session.
|
||||
This is just a placeholder and example.
|
||||
"""
|
||||
# user_profile = session.util.get_own_profile_info() # Assuming this method exists and is async or cached
|
||||
# handle = user_profile.get("handle", _("your Blueski account")) if user_profile else _("your Blueski account")
|
||||
# Expect session to expose username via db/settings
|
||||
handle = (getattr(session, "db", {}).get("user_name")
|
||||
or getattr(getattr(session, "settings", {}), "get", lambda *_: {})("blueski").get("handle")
|
||||
or _("your Bluesky account"))
|
||||
|
||||
|
||||
return {
|
||||
"text": _("Welcome to Approve for Blueski! Your account {handle} is connected.").format(handle=handle),
|
||||
# "blocks": [ # If Blueski supports a block kit like Slack or Discord
|
||||
# {
|
||||
# "type": "section",
|
||||
# "text": {
|
||||
# "type": "mrkdwn", # Or Blueski's equivalent
|
||||
# "text": _("Welcome to Approve for Blueski! Your account *{handle}* is connected.").format(handle=handle)
|
||||
# }
|
||||
# },
|
||||
# {
|
||||
# "type": "actions",
|
||||
# "elements": [
|
||||
# {
|
||||
# "type": "button",
|
||||
# "text": {"type": "plain_text", "text": _("Post your first Skeet")},
|
||||
# "action_id": "blueski_compose_new_post" # Example action ID
|
||||
# }
|
||||
# ]
|
||||
# }
|
||||
# ]
|
||||
}
|
||||
|
||||
def format_error_message(error_description: str, details: str | None = None) -> dict[str, Any]:
|
||||
"""
|
||||
Generates a standardized error message.
|
||||
"""
|
||||
message = {"text": f":warning: Error: {error_description}"} # Basic text message
|
||||
# if details:
|
||||
# message["blocks"] = [
|
||||
# {
|
||||
# "type": "section",
|
||||
# "text": {"type": "mrkdwn", "text": f":warning: *Error:* {error_description}\n{details}"}
|
||||
# }
|
||||
# ]
|
||||
return message
|
||||
|
||||
# More functions could be added here as Blueski's capabilities become clearer
|
||||
# or as specific formatting needs for Approve arise. For example:
|
||||
# - Formatting a post for display with all its embeds and cards.
|
||||
# - Generating help messages specific to Blueski features.
|
||||
# - Creating interactive messages for polls (if supported via some convention).
|
||||
|
||||
# Example of adapting a function that might exist in mastodon_messages:
|
||||
# def build_post_summary_message(session: BlueskiSession, post_uri: str, post_content: dict) -> dict[str, Any]:
|
||||
# """
|
||||
# Builds a summary message for an Blueski post.
|
||||
# """
|
||||
# author_handle = post_content.get("author", {}).get("handle", "Unknown user")
|
||||
# text_preview = post_content.get("text", "")[:100] # First 100 chars of text
|
||||
# # url = session.get_message_url(post_uri) # Assuming this method exists
|
||||
# url = f"https://bsky.app/profile/{author_handle}/post/{post_uri.split('/')[-1]}" # Construct a URL
|
||||
|
||||
# return {
|
||||
# "text": _("Post by {author_handle}: {text_preview}... ({url})").format(
|
||||
# author_handle=author_handle, text_preview=text_preview, url=url
|
||||
# ),
|
||||
# # Potentially with "blocks" for richer formatting if the platform supports it
|
||||
# }
|
||||
|
||||
logger.info("Blueski messages module loaded (placeholders).")
|
||||
|
||||
|
||||
class post(base_messages.basicMessage):
|
||||
# Bluesky character limit
|
||||
MAX_CHARS = 300
|
||||
|
||||
def __init__(self, session: Any, title: str, caption: str, text: str = "", *args, **kwargs):
|
||||
self.session = session
|
||||
self.title = title
|
||||
langs = session.supported_languages
|
||||
display_langs = [l.name for l in langs]
|
||||
self.message = postDialogs.Post(caption=caption, text=text, languages=display_langs, *args, **kwargs)
|
||||
try:
|
||||
self.message.SetTitle(title)
|
||||
self.message.text.SetInsertionPoint(len(self.message.text.GetValue()))
|
||||
except Exception:
|
||||
pass
|
||||
# Set default language
|
||||
self.set_language(session.default_language)
|
||||
# Connect events for text processing and buttons
|
||||
widgetUtils.connect_event(self.message.text, widgetUtils.ENTERED_TEXT, self.text_processor)
|
||||
widgetUtils.connect_event(self.message.spoiler, widgetUtils.ENTERED_TEXT, self.text_processor)
|
||||
widgetUtils.connect_event(self.message.spellcheck, widgetUtils.BUTTON_PRESSED, self.spellcheck)
|
||||
widgetUtils.connect_event(self.message.translate, widgetUtils.BUTTON_PRESSED, self.translate)
|
||||
widgetUtils.connect_event(self.message.autocomplete_users, widgetUtils.BUTTON_PRESSED, self.autocomplete_users)
|
||||
# Initial text processing to show character count
|
||||
self.text_processor()
|
||||
|
||||
def set_language(self, language_code=None):
|
||||
"""Set the language selection based on language code."""
|
||||
if language_code is None:
|
||||
language_code = languageHandler.curLang[:2]
|
||||
for idx, lang in enumerate(self.session.supported_languages):
|
||||
if lang.code == language_code:
|
||||
self.message.language.SetSelection(idx)
|
||||
return
|
||||
# If not found, select first item (Not set)
|
||||
self.message.language.SetSelection(0)
|
||||
|
||||
def get_language(self):
|
||||
"""Get the selected language code."""
|
||||
langs = self.session.supported_languages
|
||||
idx = self.message.language.GetSelection()
|
||||
if idx >= 0 and idx < len(langs):
|
||||
return langs[idx].code
|
||||
return None
|
||||
|
||||
def get_data(self):
|
||||
text, files, cw_text, lang_index = self.message.get_payload()
|
||||
langs = self.session.supported_languages
|
||||
lang_code = None
|
||||
if lang_index >= 0 and lang_index < len(langs):
|
||||
lang_code = langs[lang_index].code
|
||||
return text, files, cw_text, ([lang_code] if lang_code else [])
|
||||
|
||||
def text_processor(self, *args, **kwargs):
|
||||
text = self.message.text.GetValue()
|
||||
cw = self.message.spoiler.GetValue() if self.message.spoiler.IsEnabled() else ""
|
||||
char_count = len(text) + len(cw)
|
||||
self.message.SetTitle(_("%s - %s of %d characters") % (self.title, char_count, self.MAX_CHARS))
|
||||
if char_count > self.MAX_CHARS:
|
||||
self.session.sound.play("max_length.ogg")
|
||||
|
||||
def autocomplete_users(self, *args, **kwargs):
|
||||
c = completion.autocompletionUsers(self.message, self.session.session_id)
|
||||
c.show_menu()
|
||||
|
||||
|
||||
def _g(obj: Any, key: str, default: Any = None) -> Any:
|
||||
if isinstance(obj, dict):
|
||||
return obj.get(key, default)
|
||||
return getattr(obj, key, default)
|
||||
|
||||
|
||||
def has_post_data(item: Any) -> bool:
|
||||
post = _g(item, "post")
|
||||
record = _g(post, "record") if post is not None else None
|
||||
if record is None:
|
||||
record = _g(item, "record")
|
||||
return record is not None or post is not None
|
||||
|
||||
|
||||
def _extract_labels(obj: Any) -> list[dict[str, Any]]:
|
||||
labels = _g(obj, "labels", None)
|
||||
if labels is None:
|
||||
return []
|
||||
if isinstance(labels, dict):
|
||||
labels = labels.get("values", [])
|
||||
if isinstance(labels, list):
|
||||
return labels
|
||||
return []
|
||||
|
||||
|
||||
def _extract_cw_text(post: Any, record: Any) -> str:
|
||||
labels = _extract_labels(post) + _extract_labels(record)
|
||||
for label in labels:
|
||||
val = _g(label, "val", "")
|
||||
if val == "warn":
|
||||
return _("Sensitive Content")
|
||||
if isinstance(val, str) and val.startswith("warn:"):
|
||||
return val.split("warn:", 1)[-1].strip()
|
||||
return ""
|
||||
|
||||
|
||||
def _extract_image_descriptions(post: Any, record: Any) -> str:
|
||||
def _collect_images(embed: Any) -> list[Any]:
|
||||
if not embed:
|
||||
return []
|
||||
etype = _g(embed, "$type") or _g(embed, "py_type") or ""
|
||||
if "recordWithMedia" in etype:
|
||||
media = _g(embed, "media")
|
||||
mtype = _g(media, "$type") or _g(media, "py_type") or ""
|
||||
if "images" in mtype:
|
||||
return list(_g(media, "images", []) or [])
|
||||
return []
|
||||
if "images" in etype:
|
||||
return list(_g(embed, "images", []) or [])
|
||||
return []
|
||||
|
||||
images = []
|
||||
images.extend(_collect_images(_g(post, "embed")))
|
||||
if not images:
|
||||
images.extend(_collect_images(_g(record, "embed")))
|
||||
|
||||
descriptions = []
|
||||
for idx, img in enumerate(images, start=1):
|
||||
alt = _g(img, "alt", "") or ""
|
||||
if alt:
|
||||
descriptions.append(_("Image {index}: {alt}").format(index=idx, alt=alt))
|
||||
return "\n".join(descriptions)
|
||||
|
||||
|
||||
def _format_date(raw_date: str | None, offset_hours: int = 0) -> str:
|
||||
if not raw_date:
|
||||
return ""
|
||||
try:
|
||||
ts = arrow.get(raw_date)
|
||||
if offset_hours:
|
||||
ts = ts.shift(hours=offset_hours)
|
||||
return ts.format(_("dddd, MMMM D, YYYY H:m"), locale=languageHandler.curLang[:2])
|
||||
except Exception:
|
||||
return str(raw_date)[:16].replace("T", " ")
|
||||
|
||||
|
||||
def _extract_post_view_data(session: Any, item: Any) -> dict[str, Any] | None:
|
||||
post = _g(item, "post", item)
|
||||
record = _g(post, "record") or _g(item, "record")
|
||||
if record is None:
|
||||
return None
|
||||
|
||||
author = _g(post, "author") or _g(item, "author") or {}
|
||||
handle = _g(author, "handle", "")
|
||||
display_name = _g(author, "displayName") or _g(author, "display_name") or handle or _("Unknown")
|
||||
if handle and display_name != handle:
|
||||
author_label = f"{display_name} (@{handle})"
|
||||
elif handle:
|
||||
author_label = f"@{handle}"
|
||||
else:
|
||||
author_label = display_name
|
||||
|
||||
text = _g(record, "text", "") or ""
|
||||
reply_to_handle = bluesky_utils.extract_reply_to_handle(item)
|
||||
if reply_to_handle:
|
||||
if text:
|
||||
text = _("Replying to @{handle}: {text}").format(handle=reply_to_handle, text=text)
|
||||
else:
|
||||
text = _("Replying to @{handle}").format(handle=reply_to_handle)
|
||||
|
||||
quote_info = bluesky_utils.extract_quoted_post_info(item)
|
||||
if quote_info:
|
||||
if quote_info["kind"] == "not_found":
|
||||
text += f" [{_('Quoted post not found')}]"
|
||||
elif quote_info["kind"] == "blocked":
|
||||
text += f" [{_('Quoted post blocked')}]"
|
||||
elif quote_info["kind"] == "feed":
|
||||
text += f" [{_('Quoting Feed')}: {quote_info.get('feed_name', 'Feed')}]"
|
||||
else:
|
||||
q_handle = quote_info.get("handle", "unknown")
|
||||
q_text = quote_info.get("text", "")
|
||||
if q_text:
|
||||
text += " " + _("Quoting @{handle}: {text}").format(handle=q_handle, text=q_text)
|
||||
else:
|
||||
text += " " + _("Quoting @{handle}").format(handle=q_handle)
|
||||
|
||||
cw_text = _extract_cw_text(post, record)
|
||||
if cw_text:
|
||||
text = f"CW: {cw_text}\n\n{text}" if text else f"CW: {cw_text}"
|
||||
|
||||
created_at = _g(record, "createdAt") or _g(record, "created_at")
|
||||
indexed_at = _g(post, "indexedAt") or _g(post, "indexed_at")
|
||||
date = _format_date(created_at or indexed_at, offset_hours=_g(session.db, "utc_offset", 0))
|
||||
|
||||
reply_count = _g(post, "replyCount", 0) or 0
|
||||
repost_count = _g(post, "repostCount", 0) or 0
|
||||
like_count = _g(post, "likeCount", 0) or 0
|
||||
|
||||
uri = _g(post, "uri") or _g(item, "uri")
|
||||
item_url = ""
|
||||
if uri and handle:
|
||||
rkey = uri.split("/")[-1]
|
||||
item_url = f"https://bsky.app/profile/{handle}/post/{rkey}"
|
||||
|
||||
image_description = _extract_image_descriptions(post, record)
|
||||
|
||||
return {
|
||||
"author": author_label,
|
||||
"text": text,
|
||||
"date": date,
|
||||
"replies": reply_count,
|
||||
"reposts": repost_count,
|
||||
"likes": like_count,
|
||||
"source": _("Bluesky"),
|
||||
"privacy": _("Public"),
|
||||
"image_description": image_description,
|
||||
"item_url": item_url,
|
||||
}
|
||||
|
||||
|
||||
class viewPost(base_messages.basicMessage):
|
||||
def __init__(self, session: Any, item: Any, controller: Any = None):
|
||||
self.session = session
|
||||
self.controller = controller
|
||||
data = _extract_post_view_data(session, item)
|
||||
if not data:
|
||||
output.speak(_("No post available to view."), True)
|
||||
return
|
||||
self.post_uri = _g(_g(item, "post", item), "uri") or _g(item, "uri")
|
||||
title = _("Post from {}").format(data["author"])
|
||||
self.message = postDialogs.viewPost(
|
||||
text=data["text"],
|
||||
reposts_count=data["reposts"],
|
||||
likes_count=data["likes"],
|
||||
source=data["source"],
|
||||
date=data["date"],
|
||||
privacy=data["privacy"],
|
||||
)
|
||||
self.message.SetTitle(title)
|
||||
if data["image_description"]:
|
||||
self.message.image_description.Enable(True)
|
||||
self.message.image_description.ChangeValue(data["image_description"])
|
||||
widgetUtils.connect_event(self.message.spellcheck, widgetUtils.BUTTON_PRESSED, self.spellcheck)
|
||||
widgetUtils.connect_event(self.message.translateButton, widgetUtils.BUTTON_PRESSED, self.translate)
|
||||
if data["item_url"]:
|
||||
self.message.enable_button("share")
|
||||
self.item_url = data["item_url"]
|
||||
widgetUtils.connect_event(self.message.share, widgetUtils.BUTTON_PRESSED, self.share)
|
||||
if self.post_uri:
|
||||
try:
|
||||
self.message.reposts_button.Enable(True)
|
||||
self.message.likes_button.Enable(True)
|
||||
widgetUtils.connect_event(self.message.reposts_button, widgetUtils.BUTTON_PRESSED, self.on_reposts)
|
||||
widgetUtils.connect_event(self.message.likes_button, widgetUtils.BUTTON_PRESSED, self.on_likes)
|
||||
except Exception:
|
||||
pass
|
||||
self.message.ShowModal()
|
||||
|
||||
def text_processor(self):
|
||||
pass
|
||||
|
||||
def share(self, *args, **kwargs):
|
||||
if hasattr(self, "item_url"):
|
||||
output.copy(self.item_url)
|
||||
output.speak(_("Link copied to clipboard."))
|
||||
|
||||
def on_reposts(self, *args, **kwargs):
|
||||
if not self.post_uri or not self.controller:
|
||||
return
|
||||
try:
|
||||
controller = self.controller
|
||||
account_name = self.session.get_name()
|
||||
list_name = f"{self.post_uri}-reposts"
|
||||
existing = controller.search_buffer(list_name, account_name)
|
||||
if existing:
|
||||
index = controller.view.search(list_name, account_name)
|
||||
if index is not None:
|
||||
controller.view.change_buffer(index)
|
||||
return
|
||||
title = _("people who reposted this post")
|
||||
from pubsub import pub
|
||||
pub.sendMessage(
|
||||
"createBuffer",
|
||||
buffer_type="PostUserListBuffer",
|
||||
session_type="blueski",
|
||||
buffer_title=title,
|
||||
parent_tab=controller.view.search("timelines", account_name),
|
||||
start=True,
|
||||
kwargs=dict(parent=controller.view.nb, name=list_name, session=self.session,
|
||||
post_uri=self.post_uri, api_method="get_post_reposts")
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def on_likes(self, *args, **kwargs):
|
||||
if not self.post_uri or not self.controller:
|
||||
return
|
||||
try:
|
||||
controller = self.controller
|
||||
account_name = self.session.get_name()
|
||||
list_name = f"{self.post_uri}-likes"
|
||||
existing = controller.search_buffer(list_name, account_name)
|
||||
if existing:
|
||||
index = controller.view.search(list_name, account_name)
|
||||
if index is not None:
|
||||
controller.view.change_buffer(index)
|
||||
return
|
||||
title = _("people who liked this post")
|
||||
from pubsub import pub
|
||||
pub.sendMessage(
|
||||
"createBuffer",
|
||||
buffer_type="PostUserListBuffer",
|
||||
session_type="blueski",
|
||||
buffer_title=title,
|
||||
parent_tab=controller.view.search("timelines", account_name),
|
||||
start=True,
|
||||
kwargs=dict(parent=controller.view.nb, name=list_name, session=self.session,
|
||||
post_uri=self.post_uri, api_method="get_post_likes")
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
class text(base_messages.basicMessage):
|
||||
"""Simple text viewer dialog for OCR results and similar."""
|
||||
|
||||
def __init__(self, title, text="", *args, **kwargs):
|
||||
self.title = title
|
||||
self.message = postDialogs.viewText(title=title, text=text, *args, **kwargs)
|
||||
self.message.text.SetInsertionPoint(len(self.message.text.GetValue()))
|
||||
widgetUtils.connect_event(self.message.spellcheck, widgetUtils.BUTTON_PRESSED, self.spellcheck)
|
||||
widgetUtils.connect_event(self.message.translateButton, widgetUtils.BUTTON_PRESSED, self.translate)
|
||||
|
||||
def text_processor(self):
|
||||
pass
|
||||
@@ -0,0 +1,128 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
fromapprove.forms import Form, SubmitField, TextAreaField, TextField
|
||||
fromapprove.translation import translate as _
|
||||
|
||||
if TYPE_CHECKING:
|
||||
fromapprove.config import ConfigSectionProxy
|
||||
fromapprove.sessions.blueski.session import Session as BlueskiSession # Adjusted
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# This file is for defining forms and handling for Blueski-specific settings
|
||||
# that might be more complex than simple key-value pairs handled by Session.get_settings_inputs.
|
||||
# For Blueski, initial settings might be simple (handle, app password),
|
||||
# but this structure allows for expansion.
|
||||
|
||||
|
||||
class BlueskiSettingsForm(Form):
|
||||
"""
|
||||
A settings form for Blueski sessions.
|
||||
This would mirror the kind of settings found in Session.get_settings_inputs
|
||||
but using the WTForms-like Form structure for more complex validation or layout.
|
||||
"""
|
||||
# Example fields - these should align with what BlueskiSession.get_settings_inputs defines
|
||||
# and what BlueskiSession.get_configurable_values expects for its config.
|
||||
|
||||
# instance_url = TextField(
|
||||
# _("Instance URL"),
|
||||
# default="https://bsky.social", # Default PDS for Bluesky
|
||||
# description=_("The base URL of your Blueski PDS instance (e.g., https://bsky.social)."),
|
||||
# validators=[], # Add validators if needed, e.g., URL validator
|
||||
# )
|
||||
handle = TextField(
|
||||
_("Bluesky Handle"),
|
||||
description=_("Your Bluesky user handle (e.g., @username.bsky.social or username.bsky.social)."),
|
||||
validators=[], # e.g., DataRequired()
|
||||
)
|
||||
app_password = TextField( # Consider PasswordField if sensitive and your Form class supports it
|
||||
_("App Password"),
|
||||
description=_("Your Bluesky App Password. Generate this in your Bluesky account settings."),
|
||||
validators=[], # e.g., DataRequired()
|
||||
)
|
||||
# Add more fields as needed for Blueski configuration.
|
||||
# For example, if there were specific notification settings, content filters, etc.
|
||||
|
||||
submit = SubmitField(_("Save Blueski Settings"))
|
||||
|
||||
|
||||
async def get_settings_form(
|
||||
user_id: str,
|
||||
session: BlueskiSession | None = None,
|
||||
config: ConfigSectionProxy | None = None, # User-specific config for Blueski
|
||||
) -> BlueskiSettingsForm:
|
||||
"""
|
||||
Creates and pre-populates the Blueski settings form.
|
||||
"""
|
||||
form_data = {}
|
||||
if session: # If a session exists, use its current config
|
||||
# form_data["instance_url"] = session.config_get("api_base_url", "https://bsky.social")
|
||||
form_data["handle"] = session.config_get("handle", "")
|
||||
# App password should not be pre-filled for security.
|
||||
form_data["app_password"] = ""
|
||||
elif config: # Fallback to persisted config if no active session
|
||||
# form_data["instance_url"] = config.api_base_url.get("https://bsky.social")
|
||||
form_data["handle"] = config.handle.get("")
|
||||
form_data["app_password"] = ""
|
||||
|
||||
form = BlueskiSettingsForm(formdata=None, **form_data) # formdata=None for initial display
|
||||
return form
|
||||
|
||||
|
||||
async def process_settings_form(
|
||||
form: BlueskiSettingsForm,
|
||||
user_id: str,
|
||||
session: BlueskiSession | None = None, # Pass if update should affect live session
|
||||
config: ConfigSectionProxy | None = None, # User-specific config for Blueski
|
||||
) -> bool:
|
||||
"""
|
||||
Processes the submitted Blueski settings form and updates configuration.
|
||||
Returns True if successful, False otherwise.
|
||||
"""
|
||||
if not form.validate(): # Assuming form has a validate method
|
||||
logger.warning(f"Blueski settings form validation failed for user {user_id}: {form.errors}")
|
||||
return False
|
||||
|
||||
if not config and session: # Try to get config via session if not directly provided
|
||||
# This depends on how ConfigSectionProxy is obtained.
|
||||
# config = approve.config.config.sessions.blueski[user_id] # Example path
|
||||
pass # Needs actual way to get config proxy
|
||||
|
||||
if not config:
|
||||
logger.error(f"Cannot process Blueski settings for user {user_id}: no config proxy available.")
|
||||
return False
|
||||
|
||||
try:
|
||||
# Update the configuration values
|
||||
# await config.api_base_url.set(form.instance_url.data)
|
||||
await config.handle.set(form.handle.data)
|
||||
await config.app_password.set(form.app_password.data) # Ensure this is stored securely
|
||||
|
||||
logger.info(f"Blueski settings updated for user {user_id}.")
|
||||
|
||||
# If there's an active session, it might need to be reconfigured or restarted
|
||||
if session:
|
||||
logger.info(f"Requesting Blueski session re-initialization for user {user_id} due to settings change.")
|
||||
# await session.stop() # Stop it
|
||||
# # Update session instance with new values directly or rely on it re-reading config
|
||||
# session.api_base_url = form.instance_url.data
|
||||
# session.handle = form.handle.data
|
||||
# # App password should be handled carefully, session might need to re-login
|
||||
# await session.start() # Restart with new settings
|
||||
# Or, more simply, the session might have a reconfigure method:
|
||||
# await session.reconfigure(new_settings_dict)
|
||||
pass # Placeholder for session reconfiguration logic
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving Blueski settings for user {user_id}: {e}", exc_info=True)
|
||||
return False
|
||||
|
||||
# Any additional Blueski-specific settings views or handlers would go here.
|
||||
# For instance, if Blueski had features like "Relays" or "Feed Generators"
|
||||
# that needed UI configuration within Approve, those forms and handlers could be defined here.
|
||||
|
||||
logger.info("Blueski settings module loaded (placeholders).")
|
||||
@@ -0,0 +1,45 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import re
|
||||
import wx
|
||||
from typing import List
|
||||
from sessions.blueski.templates import post_variables, person_variables, notification_variables
|
||||
from wxUI.dialogs import templateDialogs
|
||||
|
||||
|
||||
class EditTemplate(object):
|
||||
def __init__(self, template: str, type: str) -> None:
|
||||
super(EditTemplate, self).__init__()
|
||||
self.default_template = template
|
||||
if type == "post":
|
||||
self.variables = post_variables
|
||||
elif type == "notification":
|
||||
self.variables = notification_variables
|
||||
else:
|
||||
self.variables = person_variables
|
||||
self.template: str = template
|
||||
|
||||
def validate_template(self, template: str) -> bool:
|
||||
used_variables: List[str] = re.findall(r"\$\w+", template)
|
||||
validated: bool = True
|
||||
for var in used_variables:
|
||||
if var[1:] not in self.variables:
|
||||
validated = False
|
||||
return validated
|
||||
|
||||
def run_dialog(self) -> str:
|
||||
dialog = templateDialogs.EditTemplateDialog(
|
||||
template=self.template,
|
||||
variables=self.variables,
|
||||
default_template=self.default_template,
|
||||
)
|
||||
response = dialog.ShowModal()
|
||||
if response == wx.ID_SAVE:
|
||||
validated: bool = self.validate_template(dialog.template.GetValue())
|
||||
if validated == False:
|
||||
templateDialogs.invalid_template()
|
||||
self.template = dialog.template.GetValue()
|
||||
return self.run_dialog()
|
||||
else:
|
||||
return dialog.template.GetValue()
|
||||
else:
|
||||
return ""
|
||||
@@ -0,0 +1,104 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import logging
|
||||
import widgetUtils
|
||||
import output
|
||||
from wxUI.dialogs.blueski import userActions as userActionsDialog
|
||||
from extra.autocompletionUsers import completion
|
||||
import languageHandler
|
||||
|
||||
log = logging.getLogger("controller.blueski.userActions")
|
||||
|
||||
|
||||
class BasicUserSelector(object):
|
||||
def __init__(self, session, users=None):
|
||||
super(BasicUserSelector, self).__init__()
|
||||
self.session = session
|
||||
self.create_dialog(users=users or [])
|
||||
|
||||
def create_dialog(self, users):
|
||||
pass
|
||||
|
||||
def resolve_profile(self, actor):
|
||||
try:
|
||||
return self.session.get_profile(actor)
|
||||
except Exception:
|
||||
log.exception("Error resolving Bluesky profile for %s.", actor)
|
||||
return None
|
||||
|
||||
def autocomplete_users(self, *args, **kwargs):
|
||||
c = completion.autocompletionUsers(self.dialog, self.session.session_id)
|
||||
c.show_menu("free")
|
||||
|
||||
|
||||
class userActions(BasicUserSelector):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(userActions, self).__init__(*args, **kwargs)
|
||||
if self.dialog.get_response() == widgetUtils.OK:
|
||||
self.process_action()
|
||||
|
||||
def create_dialog(self, users):
|
||||
self.dialog = userActionsDialog.UserActionsDialog(users)
|
||||
widgetUtils.connect_event(self.dialog.autocompletion, widgetUtils.BUTTON_PRESSED, self.autocomplete_users)
|
||||
|
||||
def process_action(self):
|
||||
action = self.dialog.get_action()
|
||||
actor = self.dialog.get_user().strip()
|
||||
if not actor:
|
||||
output.speak(_("No user specified."), True)
|
||||
return
|
||||
|
||||
profile = self.resolve_profile(actor)
|
||||
if not profile:
|
||||
output.speak(_("User not found."), True)
|
||||
return
|
||||
|
||||
def g(obj, key, default=None):
|
||||
if isinstance(obj, dict):
|
||||
return obj.get(key, default)
|
||||
return getattr(obj, key, default)
|
||||
|
||||
did = g(profile, "did")
|
||||
viewer = g(profile, "viewer") or {}
|
||||
|
||||
if not did:
|
||||
output.speak(_("User identifier not available."), True)
|
||||
return
|
||||
|
||||
if action == "follow":
|
||||
if self.session.follow_user(did):
|
||||
output.speak(_("Followed."))
|
||||
else:
|
||||
output.speak(_("Failed to follow user."), True)
|
||||
elif action == "unfollow":
|
||||
follow_uri = g(viewer, "following")
|
||||
if not follow_uri:
|
||||
output.speak(_("Follow information not available."), True)
|
||||
return
|
||||
if self.session.unfollow_user(follow_uri):
|
||||
output.speak(_("Unfollowed."))
|
||||
else:
|
||||
output.speak(_("Failed to unfollow user."), True)
|
||||
elif action == "mute":
|
||||
if self.session.mute_user(did):
|
||||
output.speak(_("Muted."))
|
||||
else:
|
||||
output.speak(_("Failed to mute user."), True)
|
||||
elif action == "unmute":
|
||||
if self.session.unmute_user(did):
|
||||
output.speak(_("Unmuted."))
|
||||
else:
|
||||
output.speak(_("Failed to unmute user."), True)
|
||||
elif action == "block":
|
||||
if self.session.block_user(did):
|
||||
output.speak(_("Blocked."))
|
||||
else:
|
||||
output.speak(_("Failed to block user."), True)
|
||||
elif action == "unblock":
|
||||
block_uri = g(viewer, "blocking")
|
||||
if not block_uri:
|
||||
output.speak(_("Block information not available."), True)
|
||||
return
|
||||
if self.session.unblock_user(block_uri):
|
||||
output.speak(_("Unblocked."))
|
||||
else:
|
||||
output.speak(_("Failed to unblock user."), True)
|
||||
@@ -0,0 +1,309 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any, AsyncGenerator
|
||||
|
||||
import widgetUtils
|
||||
from wxUI.dialogs.blueski.showUserProfile import ShowUserProfileDialog
|
||||
from controller.userList import UserListController
|
||||
from controller.blueski import userActions as user_actions_controller
|
||||
|
||||
fromapprove.translation import translate as _
|
||||
# fromapprove.controller.mastodon import userList as mastodon_user_list # If adapting
|
||||
|
||||
if TYPE_CHECKING:
|
||||
fromapprove.sessions.blueski.session import Session as BlueskiSession # Adjusted
|
||||
# Define a type for what a user entry in a list might look like for Blueski
|
||||
BlueskiUserListItem = dict[str, Any] # e.g. {"did": "...", "handle": "...", "displayName": "..."}
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# This file is responsible for fetching and managing lists of users from Blueski.
|
||||
# Examples include:
|
||||
# - Followers of a user
|
||||
# - Users a user is following
|
||||
# - Users who liked or reposted a post
|
||||
# - Users in a specific list or feed (if Blueski supports user lists like Twitter/Mastodon)
|
||||
# - Search results for users
|
||||
|
||||
# The structure will likely involve:
|
||||
# - A base class or functions for paginating through user lists from the Blueski API.
|
||||
# - Specific functions for each type of user list.
|
||||
# - Formatting Blueski user data into a consistent structure for UI display.
|
||||
|
||||
async def fetch_followers(
|
||||
session: BlueskiSession,
|
||||
user_id: str, # DID of the user whose followers to fetch
|
||||
limit: int = 20,
|
||||
cursor: str | None = None
|
||||
) -> AsyncGenerator[BlueskiUserListItem, None]:
|
||||
"""
|
||||
Asynchronously fetches a list of followers for a given Blueski user.
|
||||
user_id is the DID of the target user.
|
||||
Yields user data dictionaries.
|
||||
"""
|
||||
# client = await session.util._get_client() # Get authenticated client
|
||||
# if not client:
|
||||
# logger.warning(f"Blueski client not available for fetching followers of {user_id}.")
|
||||
# return
|
||||
|
||||
# current_cursor = cursor
|
||||
# try:
|
||||
# while True:
|
||||
# # response = await client.app.bsky.graph.get_followers(
|
||||
# # models.AppBskyGraphGetFollowers.Params(
|
||||
# # actor=user_id,
|
||||
# # limit=min(limit, 100), # ATProto API might have its own max limit per request (e.g. 100)
|
||||
# # cursor=current_cursor
|
||||
# # )
|
||||
# # )
|
||||
# # if not response or not response.followers:
|
||||
# # break
|
||||
|
||||
# # for user_profile_view in response.followers:
|
||||
# # yield session.util._format_profile_data(user_profile_view) # Use a utility to standardize format
|
||||
|
||||
# # current_cursor = response.cursor
|
||||
# # if not current_cursor or len(response.followers) < limit : # Or however the API indicates end of list
|
||||
# # break
|
||||
|
||||
# # This is a placeholder loop for demonstration
|
||||
# if current_cursor == "simulated_end_cursor": break # Stop after one simulated page
|
||||
# for i in range(limit):
|
||||
# if current_cursor and int(current_cursor) + i >= 25: # Simulate total 25 followers
|
||||
# current_cursor = "simulated_end_cursor"
|
||||
# break
|
||||
# yield {
|
||||
# "did": f"did:plc:follower{i + (int(current_cursor) if current_cursor else 0)}",
|
||||
# "handle": f"follower{i + (int(current_cursor) if current_cursor else 0)}.bsky.social",
|
||||
# "displayName": f"Follower {i + (int(current_cursor) if current_cursor else 0)}",
|
||||
# "avatar": None # Placeholder
|
||||
# }
|
||||
# if not current_cursor: current_cursor = str(limit) # Simulate next cursor
|
||||
# elif current_cursor != "simulated_end_cursor": current_cursor = str(int(current_cursor) + limit)
|
||||
|
||||
|
||||
"""
|
||||
if not session.is_ready():
|
||||
logger.warning(f"Cannot fetch followers for {user_id}: Blueski session not ready.")
|
||||
# yield {} # Stop iteration if not ready
|
||||
return
|
||||
|
||||
try:
|
||||
followers_data = await session.util.get_followers(user_did=user_id, limit=limit, cursor=cursor)
|
||||
if followers_data:
|
||||
users, _ = followers_data # We'll return the cursor separately via the calling HTTP handler
|
||||
for user_profile_view in users:
|
||||
yield session.util._format_profile_data(user_profile_view)
|
||||
else:
|
||||
logger.info(f"No followers data returned for user {user_id}.")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in fetch_followers for Blueski user {user_id}: {e}", exc_info=True)
|
||||
# Depending on desired error handling, could raise or yield an error marker
|
||||
|
||||
|
||||
async def fetch_following(
|
||||
session: BlueskiSession,
|
||||
user_id: str, # DID of the user whose followed accounts to fetch
|
||||
limit: int = 20,
|
||||
cursor: str | None = None
|
||||
) -> AsyncGenerator[BlueskiUserListItem, None]:
|
||||
"""
|
||||
Asynchronously fetches a list of users followed by a given Blueski user.
|
||||
Yields user data dictionaries.
|
||||
"""
|
||||
if not session.is_ready():
|
||||
logger.warning(f"Cannot fetch following for {user_id}: Blueski session not ready.")
|
||||
return
|
||||
|
||||
try:
|
||||
following_data = await session.util.get_following(user_did=user_id, limit=limit, cursor=cursor)
|
||||
if following_data:
|
||||
users, _ = following_data
|
||||
for user_profile_view in users:
|
||||
yield session.util._format_profile_data(user_profile_view)
|
||||
else:
|
||||
logger.info(f"No following data returned for user {user_id}.")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in fetch_following for Blueski user {user_id}: {e}", exc_info=True)
|
||||
|
||||
|
||||
async def search_users(
|
||||
session: BlueskiSession,
|
||||
query: str,
|
||||
limit: int = 20,
|
||||
cursor: str | None = None
|
||||
) -> AsyncGenerator[BlueskiUserListItem, None]:
|
||||
"""
|
||||
Searches for users on Blueski based on a query string.
|
||||
Yields user data dictionaries.
|
||||
"""
|
||||
if not session.is_ready():
|
||||
logger.warning(f"Cannot search users for '{query}': Blueski session not ready.")
|
||||
return
|
||||
|
||||
try:
|
||||
search_data = await session.util.search_users(term=query, limit=limit, cursor=cursor)
|
||||
if search_data:
|
||||
users, _ = search_data
|
||||
for user_profile_view in users:
|
||||
yield session.util._format_profile_data(user_profile_view)
|
||||
else:
|
||||
logger.info(f"No users found for search term '{query}'.")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in search_users for Blueski query '{query}': {e}", exc_info=True)
|
||||
|
||||
# This function is designed to be called by an API endpoint that returns JSON
|
||||
async def get_user_list_paginated(
|
||||
session: BlueskiSession,
|
||||
list_type: str, # "followers", "following", "search"
|
||||
identifier: str, # User DID for followers/following, or search query for search
|
||||
limit: int = 20,
|
||||
cursor: str | None = None
|
||||
) -> tuple[list[BlueskiUserListItem], str | None]:
|
||||
"""
|
||||
Fetches a paginated list of users (followers, following, or search results)
|
||||
and returns the list and the next cursor.
|
||||
"""
|
||||
users_list: list[BlueskiUserListItem] = []
|
||||
next_cursor: str | None = None
|
||||
|
||||
if not session.is_ready():
|
||||
logger.warning(f"Cannot fetch user list '{list_type}': Blueski session not ready.")
|
||||
return [], None
|
||||
|
||||
try:
|
||||
if list_type == "followers":
|
||||
data = await session.util.get_followers(user_did=identifier, limit=limit, cursor=cursor)
|
||||
if data: users_list = [session.util._format_profile_data(u) for u in data[0]]; next_cursor = data[1]
|
||||
elif list_type == "following":
|
||||
data = await session.util.get_following(user_did=identifier, limit=limit, cursor=cursor)
|
||||
if data: users_list = [session.util._format_profile_data(u) for u in data[0]]; next_cursor = data[1]
|
||||
elif list_type == "search_users":
|
||||
data = await session.util.search_users(term=identifier, limit=limit, cursor=cursor)
|
||||
if data: users_list = [session.util._format_profile_data(u) for u in data[0]]; next_cursor = data[1]
|
||||
else:
|
||||
logger.error(f"Unknown list_type: {list_type}")
|
||||
return [], None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching paginated user list '{list_type}' for '{identifier}': {e}", exc_info=True)
|
||||
# Optionally re-raise or return empty with no cursor to indicate error
|
||||
return [], None
|
||||
|
||||
return users_list, next_cursor
|
||||
|
||||
|
||||
async def get_user_profile_details(session: BlueskiSession, user_ident: str) -> BlueskiUserListItem | None:
|
||||
"""
|
||||
Fetches detailed profile information for a user by DID or handle.
|
||||
Returns a dictionary of formatted profile data, or None if not found/error.
|
||||
"""
|
||||
if not session.is_ready():
|
||||
logger.warning(f"Cannot get profile for {user_ident}: Blueski session not ready.")
|
||||
return None
|
||||
|
||||
try:
|
||||
profile_view_detailed = await session.util.get_user_profile(user_ident=user_ident)
|
||||
if profile_view_detailed:
|
||||
return session.util._format_profile_data(profile_view_detailed)
|
||||
else:
|
||||
logger.info(f"No profile data found for user {user_ident}.")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Error in get_user_profile_details for {user_ident}: {e}", exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
# Other list types could include:
|
||||
# - fetch_likers(session, post_uri, limit, cursor) # Needs app.bsky.feed.getLikes
|
||||
# - fetch_reposters(session, post_uri, limit, cursor)
|
||||
# - fetch_muted_users(session, limit, cursor)
|
||||
# - fetch_blocked_users(session, limit, cursor)
|
||||
|
||||
# The UI part of Approve that displays user lists would call these functions.
|
||||
# Each function needs to handle pagination as provided by the ATProto API (usually cursor-based).
|
||||
|
||||
logger.info("Blueski userList module loaded (placeholders).")
|
||||
|
||||
|
||||
class BlueskyUserList(UserListController):
|
||||
def __init__(self, users, session, title, fetch_fn=None, cursor=None):
|
||||
self.session = session
|
||||
self.users = self.process_users(users)
|
||||
self._fetch_fn = fetch_fn
|
||||
self._cursor = cursor
|
||||
from wxUI.dialogs import userList
|
||||
self.dialog = userList.UserListDialog(title=title, users=[user.get("display_name") for user in self.users])
|
||||
widgetUtils.connect_event(self.dialog.actions_button, widgetUtils.BUTTON_PRESSED, self.on_actions)
|
||||
widgetUtils.connect_event(self.dialog.details_button, widgetUtils.BUTTON_PRESSED, self.on_details)
|
||||
self._enable_pagination()
|
||||
self.dialog.ShowModal()
|
||||
def process_users(self, users):
|
||||
def g(obj, key, default=None):
|
||||
if isinstance(obj, dict):
|
||||
return obj.get(key, default)
|
||||
return getattr(obj, key, default)
|
||||
|
||||
processed = []
|
||||
for item in users or []:
|
||||
actor = g(item, "actor") or g(item, "user") or item
|
||||
did = g(actor, "did")
|
||||
handle = g(actor, "handle")
|
||||
display_name = g(actor, "displayName") or g(actor, "display_name") or handle or "Unknown"
|
||||
label = f"{display_name} (@{handle})" if handle and display_name != handle else (f"@{handle}" if handle else display_name)
|
||||
processed.append(dict(did=did, handle=handle, display_name=label))
|
||||
return processed
|
||||
|
||||
def on_actions(self, *args, **kwargs):
|
||||
idx = self.dialog.user_list.GetSelection()
|
||||
if idx < 0 or idx >= len(self.users):
|
||||
return
|
||||
handle = self.users[idx].get("handle")
|
||||
if not handle:
|
||||
return
|
||||
user_actions_controller.userActions(self.session, [handle])
|
||||
|
||||
def on_details(self, *args, **kwargs):
|
||||
idx = self.dialog.user_list.GetSelection()
|
||||
if idx < 0 or idx >= len(self.users):
|
||||
return
|
||||
user_ident = self.users[idx].get("did") or self.users[idx].get("handle")
|
||||
if not user_ident:
|
||||
return
|
||||
dlg = ShowUserProfileDialog(self.dialog, self.session, user_ident)
|
||||
dlg.ShowModal()
|
||||
dlg.Destroy()
|
||||
|
||||
def _enable_pagination(self):
|
||||
if not self._fetch_fn:
|
||||
return
|
||||
if not self._cursor:
|
||||
return
|
||||
self.dialog.load_more_button.Show()
|
||||
widgetUtils.connect_event(self.dialog.load_more_button, widgetUtils.BUTTON_PRESSED, self.load_more)
|
||||
self.dialog.Layout()
|
||||
|
||||
def load_more(self, *args, **kwargs):
|
||||
if not self._fetch_fn:
|
||||
return
|
||||
if not self._cursor:
|
||||
self.dialog.load_more_button.Disable()
|
||||
return
|
||||
try:
|
||||
res = self._fetch_fn(cursor=self._cursor)
|
||||
items = res.get("items", []) if isinstance(res, dict) else []
|
||||
self._cursor = res.get("cursor") if isinstance(res, dict) else None
|
||||
new_users = self.process_users(items)
|
||||
if not new_users:
|
||||
self.dialog.load_more_button.Disable()
|
||||
return
|
||||
self.users.extend(new_users)
|
||||
self.dialog.add_users([u.get("display_name") for u in new_users])
|
||||
if not self._cursor:
|
||||
self.dialog.load_more_button.Disable()
|
||||
except Exception:
|
||||
self.dialog.load_more_button.Disable()
|
||||
@@ -10,7 +10,7 @@ from . import base
|
||||
log = logging.getLogger("controller.buffers.base.account")
|
||||
|
||||
class AccountBuffer(base.Buffer):
|
||||
def __init__(self, parent, name, account, account_id):
|
||||
def __init__(self, parent, name, account, account_id, session=None):
|
||||
super(AccountBuffer, self).__init__(parent, None, name)
|
||||
log.debug("Initializing buffer %s, account %s" % (name, account,))
|
||||
self.buffer = buffers.accountPanel(parent, name)
|
||||
@@ -53,4 +53,4 @@ class AccountBuffer(base.Buffer):
|
||||
else:
|
||||
self.buffer.change_autostart(False)
|
||||
config.app["sessions"]["ignored_sessions"].append(self.account_id)
|
||||
config.app.write()
|
||||
config.app.write()
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from .timeline import (
|
||||
HomeTimeline,
|
||||
FollowingTimeline,
|
||||
NotificationBuffer,
|
||||
Conversation,
|
||||
LikesBuffer,
|
||||
MentionsBuffer,
|
||||
SentBuffer,
|
||||
UserTimeline,
|
||||
SearchBuffer,
|
||||
)
|
||||
from .user import FollowersBuffer, FollowingBuffer, BlocksBuffer, PostUserListBuffer
|
||||
from .chat import ConversationListBuffer, ChatBuffer as ChatMessageBuffer
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,559 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import logging
|
||||
import wx
|
||||
import widgetUtils
|
||||
import output
|
||||
from .base import BaseBuffer
|
||||
from controller.blueski import messages as blueski_messages
|
||||
from wxUI.buffers.blueski import panels as BlueskiPanels
|
||||
from sessions.blueski import compose
|
||||
from mysc.thread_utils import call_threaded
|
||||
|
||||
log = logging.getLogger("controller.buffers.blueski.chat")
|
||||
|
||||
class ConversationListBuffer(BaseBuffer):
|
||||
"""Buffer for listing conversations, similar to Mastodon's ConversationListBuffer."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
kwargs["compose_func"] = "compose_convo"
|
||||
super(ConversationListBuffer, self).__init__(*args, **kwargs)
|
||||
self.type = "chat"
|
||||
self.sound = "dm_received.ogg"
|
||||
|
||||
def create_buffer(self, parent, name):
|
||||
self.buffer = BlueskiPanels.ChatPanel(parent, name)
|
||||
self.buffer.session = self.session
|
||||
|
||||
def bind_events(self):
|
||||
"""Bind events like Mastodon's ConversationListBuffer."""
|
||||
self.buffer.set_focus_function(self.onFocus)
|
||||
widgetUtils.connect_event(self.buffer.list.list, widgetUtils.KEYPRESS, self.get_event)
|
||||
widgetUtils.connect_event(self.buffer.list.list, wx.EVT_LIST_ITEM_RIGHT_CLICK, self.show_menu)
|
||||
widgetUtils.connect_event(self.buffer.list.list, wx.EVT_LIST_KEY_DOWN, self.show_menu_by_key)
|
||||
# Buttons
|
||||
if hasattr(self.buffer, "post"):
|
||||
widgetUtils.connect_event(self.buffer, widgetUtils.BUTTON_PRESSED, self.on_post, self.buffer.post)
|
||||
if hasattr(self.buffer, "reply"):
|
||||
widgetUtils.connect_event(self.buffer, widgetUtils.BUTTON_PRESSED, self.reply, self.buffer.reply)
|
||||
if hasattr(self.buffer, "new_chat"):
|
||||
widgetUtils.connect_event(self.buffer, widgetUtils.BUTTON_PRESSED, self.on_new_chat, self.buffer.new_chat)
|
||||
|
||||
def get_item(self):
|
||||
"""Get the last message from the selected conversation (like Mastodon)."""
|
||||
index = self.buffer.list.get_selected()
|
||||
if index > -1 and self.session.db.get(self.name) is not None and len(self.session.db[self.name]) > index:
|
||||
convo = self.session.db[self.name][index]
|
||||
# Return lastMessage for compatibility with item-based operations
|
||||
last_msg = getattr(convo, "lastMessage", None) or (convo.get("lastMessage") if isinstance(convo, dict) else None)
|
||||
return last_msg
|
||||
return None
|
||||
|
||||
def get_conversation(self):
|
||||
"""Get the full conversation object."""
|
||||
index = self.buffer.list.get_selected()
|
||||
if index > -1 and self.session.db.get(self.name) is not None and len(self.session.db[self.name]) > index:
|
||||
return self.session.db[self.name][index]
|
||||
return None
|
||||
|
||||
def get_convo_id(self, conversation):
|
||||
"""Extract conversation ID from a conversation object, handling different field names."""
|
||||
if not conversation:
|
||||
return None
|
||||
# Try different possible field names for the conversation ID
|
||||
for attr in ("id", "convo_id", "convoId"):
|
||||
val = getattr(conversation, attr, None)
|
||||
if val:
|
||||
return val
|
||||
if isinstance(conversation, dict) and attr in conversation:
|
||||
return conversation[attr]
|
||||
return None
|
||||
|
||||
def _get_members_key(self, conversation):
|
||||
"""Fallback key when convo id is missing: stable member DID list."""
|
||||
members = getattr(conversation, "members", None) or (conversation.get("members") if isinstance(conversation, dict) else None) or []
|
||||
dids = []
|
||||
for m in members:
|
||||
did = getattr(m, "did", None) or (m.get("did") if isinstance(m, dict) else None)
|
||||
if did:
|
||||
dids.append(did)
|
||||
if not dids:
|
||||
return None
|
||||
dids.sort()
|
||||
return tuple(dids)
|
||||
|
||||
def _get_last_message_key(self, conversation):
|
||||
"""Key for detecting conversation updates based on last message."""
|
||||
last_msg = getattr(conversation, "lastMessage", None) or (conversation.get("lastMessage") if isinstance(conversation, dict) else None)
|
||||
if not last_msg:
|
||||
return None
|
||||
|
||||
def g(obj, key, default=None):
|
||||
if isinstance(obj, dict):
|
||||
return obj.get(key, default)
|
||||
return getattr(obj, key, default)
|
||||
|
||||
for attr in ("id", "messageId", "message_id", "msgId", "msg_id", "cid", "rev"):
|
||||
val = g(last_msg, attr)
|
||||
if val:
|
||||
return str(val)
|
||||
|
||||
nested = g(last_msg, "message") or g(last_msg, "record")
|
||||
if nested:
|
||||
for attr in ("id", "messageId", "message_id", "msgId", "msg_id", "cid", "rev"):
|
||||
val = g(nested, attr)
|
||||
if val:
|
||||
return str(val)
|
||||
|
||||
sent_at = g(last_msg, "sentAt") or g(last_msg, "sent_at") or g(last_msg, "createdAt") or g(last_msg, "created_at")
|
||||
sender = g(last_msg, "sender") or (g(nested, "sender") if nested else {}) or {}
|
||||
sender_did = g(sender, "did")
|
||||
text = g(last_msg, "text") or (g(nested, "text") if nested else None)
|
||||
if sent_at or sender_did or text:
|
||||
return (str(sent_at) if sent_at else None, str(sender_did) if sender_did else None, str(text) if text else None)
|
||||
return None
|
||||
|
||||
def get_formatted_message(self):
|
||||
"""Return last message text for current conversation."""
|
||||
conversation = self.get_conversation()
|
||||
if not conversation:
|
||||
return None
|
||||
return self.compose_function(
|
||||
conversation,
|
||||
self.session.db,
|
||||
self.session.settings,
|
||||
self.session.settings["general"].get("relative_times", False),
|
||||
self.session.settings["general"].get("show_screen_names", False),
|
||||
)[1]
|
||||
|
||||
def get_message(self):
|
||||
"""Return a readable summary for the selected conversation."""
|
||||
conversation = self.get_conversation()
|
||||
if not conversation:
|
||||
return None
|
||||
composed = self.compose_function(
|
||||
conversation,
|
||||
self.session.db,
|
||||
self.session.settings,
|
||||
self.session.settings["general"].get("relative_times", False),
|
||||
self.session.settings["general"].get("show_screen_names", False),
|
||||
)
|
||||
return " ".join(composed)
|
||||
|
||||
def on_new_chat(self, *args, **kwargs):
|
||||
"""Start a new conversation by entering a handle."""
|
||||
dlg = wx.TextEntryDialog(None, _("Enter the handle of the user (e.g., user.bsky.social):"), _("New Chat"))
|
||||
if dlg.ShowModal() == wx.ID_OK:
|
||||
handle = dlg.GetValue().strip()
|
||||
if handle:
|
||||
if handle.startswith("@"):
|
||||
handle = handle[1:]
|
||||
def do_create():
|
||||
try:
|
||||
# Resolve handle to DID
|
||||
profile = self.session.get_profile(handle)
|
||||
if not profile:
|
||||
wx.CallAfter(output.speak, _("User not found."), True)
|
||||
return
|
||||
did = getattr(profile, "did", None) or (profile.get("did") if isinstance(profile, dict) else None)
|
||||
if not did:
|
||||
wx.CallAfter(output.speak, _("Could not get user ID."), True)
|
||||
return
|
||||
# Get or create conversation
|
||||
convo = self.session.get_or_create_convo([did])
|
||||
if not convo:
|
||||
wx.CallAfter(output.speak, _("Could not create conversation."), True)
|
||||
return
|
||||
convo_id = self.get_convo_id(convo)
|
||||
user_handle = getattr(profile, "handle", None) or (profile.get("handle") if isinstance(profile, dict) else None) or handle
|
||||
title = _("Chat: {0}").format(user_handle)
|
||||
# Create the buffer under direct_messages node
|
||||
wx.CallAfter(self._create_chat_buffer, self.controller, title, convo_id)
|
||||
# Refresh conversation list
|
||||
wx.CallAfter(self.start_stream, True, False)
|
||||
except Exception:
|
||||
log.exception("Error creating new conversation")
|
||||
wx.CallAfter(output.speak, _("Error creating conversation."), True)
|
||||
call_threaded(do_create)
|
||||
dlg.Destroy()
|
||||
|
||||
def start_stream(self, mandatory=False, play_sound=True, avoid_autoreading=False):
|
||||
count = self.get_max_items()
|
||||
try:
|
||||
res = self.session.list_convos(limit=count)
|
||||
items = res.get("items", [])
|
||||
self._build_member_maps(items)
|
||||
return self._merge_conversations(items, play_sound, avoid_autoreading=avoid_autoreading)
|
||||
except Exception:
|
||||
log.exception("Error fetching conversations")
|
||||
output.speak(_("Error loading conversations."), True)
|
||||
return 0
|
||||
|
||||
def _build_member_maps(self, convos):
|
||||
"""Build DID→name maps from conversation members and store in db for chat buffers."""
|
||||
def g(obj, key, default=None):
|
||||
if isinstance(obj, dict):
|
||||
return obj.get(key, default)
|
||||
return getattr(obj, key, default)
|
||||
for convo in convos:
|
||||
convo_id = self.get_convo_id(convo)
|
||||
if not convo_id:
|
||||
continue
|
||||
members = g(convo, "members", []) or []
|
||||
member_map = {}
|
||||
for m in members:
|
||||
did = g(m, "did", None)
|
||||
if did:
|
||||
name = g(m, "display_name") or g(m, "displayName") or g(m, "handle", "unknown")
|
||||
member_map[did] = name
|
||||
if member_map:
|
||||
self.session.db["convo_" + str(convo_id) + "_members"] = member_map
|
||||
|
||||
def _merge_conversations(self, items, play_sound=True, avoid_autoreading=False):
|
||||
"""Merge conversation list, updating items without duplicating or re-alerting."""
|
||||
if self.session.db.get(self.name) is None:
|
||||
self.session.db[self.name] = []
|
||||
|
||||
# Track current selection to restore after refresh
|
||||
selected_key = None
|
||||
current_convo = self.get_conversation()
|
||||
if current_convo:
|
||||
selected_key = self.get_convo_id(current_convo) or self._get_members_key(current_convo)
|
||||
|
||||
existing = {}
|
||||
existing_last = {}
|
||||
for convo in self.session.db[self.name]:
|
||||
key = self.get_convo_id(convo) or self._get_members_key(convo)
|
||||
if key is None:
|
||||
continue
|
||||
existing[key] = convo
|
||||
existing_last[key] = self._get_last_message_key(convo)
|
||||
|
||||
new_db = []
|
||||
new_count = 0
|
||||
first_load = len(self.session.db[self.name]) == 0
|
||||
for convo in items:
|
||||
key = self.get_convo_id(convo) or self._get_members_key(convo)
|
||||
new_db.append(convo)
|
||||
if key is None:
|
||||
if first_load:
|
||||
new_count += 1
|
||||
continue
|
||||
if key not in existing:
|
||||
new_count += 1
|
||||
continue
|
||||
if self._get_last_message_key(convo) != existing_last.get(key):
|
||||
new_count += 1
|
||||
|
||||
# Replace DB with latest ordered list from API
|
||||
self.session.db[self.name] = new_db
|
||||
|
||||
# Rebuild list UI to keep ordering consistent with API
|
||||
self.buffer.list.clear()
|
||||
safe = True
|
||||
relative_times = self.session.settings["general"].get("relative_times", False)
|
||||
show_screen_names = self.session.settings["general"].get("show_screen_names", False)
|
||||
for convo in new_db:
|
||||
row = self.compose_function(convo, self.session.db, self.session.settings, relative_times, show_screen_names, safe=safe)
|
||||
self.buffer.list.insert_item(False, *row)
|
||||
|
||||
# Restore selection if possible
|
||||
if selected_key is not None:
|
||||
for idx, convo in enumerate(new_db):
|
||||
key = self.get_convo_id(convo) or self._get_members_key(convo)
|
||||
if key == selected_key:
|
||||
self.buffer.list.select_item(idx)
|
||||
break
|
||||
|
||||
# Sound and auto-read only when something actually changed
|
||||
if new_count > 0:
|
||||
if play_sound and self.sound and not self.session.settings["sound"].get("session_mute", False):
|
||||
self.session.sound.play(self.sound)
|
||||
if not avoid_autoreading:
|
||||
self.auto_read(new_count)
|
||||
|
||||
return new_count
|
||||
|
||||
def fav(self):
|
||||
pass
|
||||
|
||||
def unfav(self):
|
||||
pass
|
||||
|
||||
def can_share(self):
|
||||
return False
|
||||
|
||||
def url(self, *args, **kwargs):
|
||||
"""Enter key opens the chat conversation buffer."""
|
||||
self.view_chat()
|
||||
|
||||
def send_message(self, *args, **kwargs):
|
||||
"""Global DM shortcut - reply to conversation."""
|
||||
return self.reply()
|
||||
|
||||
def reply(self, *args, **kwargs):
|
||||
"""Reply to the selected conversation (like Mastodon)."""
|
||||
conversation = self.get_conversation()
|
||||
if not conversation:
|
||||
output.speak(_("No conversation selected."), True)
|
||||
return
|
||||
|
||||
convo_id = self.get_convo_id(conversation)
|
||||
if not convo_id:
|
||||
log.error("Could not get conversation ID from conversation object")
|
||||
output.speak(_("Could not identify conversation."), True)
|
||||
return
|
||||
|
||||
# Get participants for title
|
||||
members = getattr(conversation, "members", []) or (conversation.get("members", []) if isinstance(conversation, dict) else [])
|
||||
user_did = self.session.db.get("user_id")
|
||||
others = [m for m in members if (getattr(m, "did", None) or (m.get("did") if isinstance(m, dict) else None)) != user_did]
|
||||
if not others:
|
||||
others = members
|
||||
|
||||
if others:
|
||||
first_user = others[0]
|
||||
username = getattr(first_user, "handle", None) or (first_user.get("handle") if isinstance(first_user, dict) else None) or "unknown"
|
||||
else:
|
||||
username = "unknown"
|
||||
|
||||
title = _("Conversation with {0}").format(username)
|
||||
caption = _("Write your message here")
|
||||
initial_text = "@{} ".format(username)
|
||||
|
||||
post = blueski_messages.post(session=self.session, title=title, caption=caption, text=initial_text)
|
||||
if post.message.ShowModal() == wx.ID_OK:
|
||||
text, files, cw_text, langs = post.get_data()
|
||||
if text:
|
||||
def do_send():
|
||||
try:
|
||||
self.session.send_chat_message(convo_id, text)
|
||||
wx.CallAfter(self.session.sound.play, "dm_sent.ogg")
|
||||
wx.CallAfter(output.speak, _("Message sent."))
|
||||
wx.CallAfter(self.start_stream, True, False)
|
||||
except Exception:
|
||||
log.exception("Error sending message")
|
||||
wx.CallAfter(output.speak, _("Failed to send message."), True)
|
||||
call_threaded(do_send)
|
||||
if hasattr(post.message, "Destroy"):
|
||||
post.message.Destroy()
|
||||
|
||||
def view_chat(self):
|
||||
"""Open the conversation in a separate buffer (nested under Chats node)."""
|
||||
conversation = self.get_conversation()
|
||||
if not conversation:
|
||||
output.speak(_("No conversation selected."), True)
|
||||
return
|
||||
|
||||
convo_id = self.get_convo_id(conversation)
|
||||
if not convo_id:
|
||||
log.error("Could not get conversation ID from conversation object: %r", conversation)
|
||||
output.speak(_("Could not identify conversation."), True)
|
||||
return
|
||||
|
||||
# Determine participants names for title
|
||||
members = getattr(conversation, "members", []) or (conversation.get("members", []) if isinstance(conversation, dict) else [])
|
||||
user_did = self.session.db.get("user_id")
|
||||
others = [m for m in members if (getattr(m, "did", None) or (m.get("did") if isinstance(m, dict) else None)) != user_did]
|
||||
if not others:
|
||||
others = members
|
||||
names = ", ".join([getattr(m, "handle", None) or (m.get("handle") if isinstance(m, dict) else None) or "unknown" for m in others])
|
||||
|
||||
title = _("Chat: {0}").format(names)
|
||||
|
||||
self._create_chat_buffer(self.controller, title, convo_id)
|
||||
|
||||
def _create_chat_buffer(self, controller, title, convo_id):
|
||||
"""Create a chat buffer under the direct_messages node, avoiding duplicates."""
|
||||
account_name = self.session.get_name()
|
||||
|
||||
# Avoid duplicates: if buffer already exists, navigate to it
|
||||
existing = controller.search_buffer(title, account_name)
|
||||
if existing:
|
||||
index = controller.view.search(title, account_name)
|
||||
if index is not None:
|
||||
controller.view.change_buffer(index)
|
||||
return
|
||||
|
||||
# Insert under direct_messages node (like Mastodon's ConversationBuffer)
|
||||
chats_position = controller.view.search("direct_messages", account_name)
|
||||
if chats_position is None:
|
||||
chats_position = controller.view.search(account_name, account_name)
|
||||
|
||||
controller.create_buffer(
|
||||
buffer_type="chat_messages",
|
||||
session_type="blueski",
|
||||
buffer_title=title,
|
||||
parent_tab=chats_position,
|
||||
kwargs={"session": self.session, "convo_id": convo_id, "name": title},
|
||||
start=True
|
||||
)
|
||||
|
||||
# Navigate to the newly created buffer and announce it
|
||||
new_index = controller.view.search(title, account_name)
|
||||
if new_index is not None:
|
||||
controller.view.change_buffer(new_index)
|
||||
buffer_obj = controller.search_buffer(title, account_name)
|
||||
if buffer_obj and hasattr(buffer_obj.buffer, "list"):
|
||||
try:
|
||||
count = buffer_obj.buffer.list.get_count()
|
||||
if count > 0:
|
||||
msg = _("{0}, {1} of {2}").format(title, buffer_obj.buffer.list.get_selected()+1, count)
|
||||
else:
|
||||
msg = _("{0}. Empty").format(title)
|
||||
except Exception:
|
||||
msg = _("{0}. Empty").format(title)
|
||||
output.speak(msg, True)
|
||||
|
||||
def destroy_status(self):
|
||||
pass
|
||||
|
||||
class ChatBuffer(BaseBuffer):
|
||||
"""Buffer for displaying messages in a conversation, similar to Mastodon's ConversationBuffer."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
kwargs["compose_func"] = "compose_chat_message"
|
||||
super(ChatBuffer, self).__init__(*args, **kwargs)
|
||||
self.type = "chat_messages"
|
||||
self.convo_id = kwargs.get("convo_id")
|
||||
self.sound = "dm_received.ogg"
|
||||
self._member_map_loaded = False
|
||||
|
||||
def create_buffer(self, parent, name):
|
||||
self.buffer = BlueskiPanels.ChatMessagePanel(parent, name)
|
||||
self.buffer.session = self.session
|
||||
|
||||
def _update_member_map(self):
|
||||
"""Fetch conversation members to build a DID-to-name map for sender resolution."""
|
||||
try:
|
||||
convo = self.session.get_convo(self.convo_id)
|
||||
if not convo:
|
||||
return
|
||||
member_map = {}
|
||||
for m in getattr(convo, "members", []) or []:
|
||||
did = getattr(m, "did", None)
|
||||
if did:
|
||||
name = getattr(m, "display_name", None) or getattr(m, "handle", None) or "unknown"
|
||||
member_map[did] = name
|
||||
self.session.db[self.name + "_members"] = member_map
|
||||
except Exception:
|
||||
log.exception("Error fetching conversation members for DID resolution")
|
||||
|
||||
def start_stream(self, mandatory=False, play_sound=True):
|
||||
if not self.convo_id:
|
||||
return 0
|
||||
if not self._member_map_loaded:
|
||||
self._update_member_map()
|
||||
self._member_map_loaded = True
|
||||
count = self.get_max_items()
|
||||
try:
|
||||
res = self.session.get_convo_messages(self.convo_id, limit=count)
|
||||
items = res.get("items", [])
|
||||
items = list(reversed(items))
|
||||
return self.process_items(items, play_sound)
|
||||
except Exception:
|
||||
log.exception("Error fetching chat messages")
|
||||
return 0
|
||||
|
||||
def get_more_items(self):
|
||||
output.speak(_("This action is not supported for this buffer"), True)
|
||||
|
||||
def fav(self):
|
||||
pass
|
||||
|
||||
def unfav(self):
|
||||
pass
|
||||
|
||||
def can_share(self):
|
||||
return False
|
||||
|
||||
def destroy_status(self):
|
||||
pass
|
||||
|
||||
def url(self, *args, **kwargs):
|
||||
"""Enter key opens reply dialog in chat."""
|
||||
self.on_reply(None)
|
||||
|
||||
def on_reply(self, evt):
|
||||
"""Open dialog to send a message in this conversation."""
|
||||
if not self.convo_id:
|
||||
output.speak(_("Cannot send message: no conversation selected."), True)
|
||||
return
|
||||
|
||||
# Get conversation title from buffer name or use generic
|
||||
title = _("Send Message")
|
||||
if self.name and self.name.startswith(_("Chat: ")):
|
||||
title = self.name
|
||||
caption = _("Write your message here")
|
||||
|
||||
post = blueski_messages.post(session=self.session, title=title, caption=caption, text="")
|
||||
if post.message.ShowModal() == wx.ID_OK:
|
||||
text, files, cw_text, langs = post.get_data()
|
||||
if text:
|
||||
def do_send():
|
||||
try:
|
||||
self.session.send_chat_message(self.convo_id, text)
|
||||
wx.CallAfter(self.session.sound.play, "dm_sent.ogg")
|
||||
wx.CallAfter(output.speak, _("Message sent."))
|
||||
wx.CallAfter(self.start_stream, True, False)
|
||||
except Exception:
|
||||
log.exception("Error sending chat message")
|
||||
wx.CallAfter(output.speak, _("Failed to send message."), True)
|
||||
call_threaded(do_send)
|
||||
if hasattr(post.message, "Destroy"):
|
||||
post.message.Destroy()
|
||||
|
||||
def reply(self, *args, **kwargs):
|
||||
"""Handle reply action (from menu or keyboard shortcut)."""
|
||||
self.on_reply(None)
|
||||
|
||||
def send_message(self, *args, **kwargs):
|
||||
"""Global shortcut for DM."""
|
||||
self.on_reply(None)
|
||||
|
||||
def get_message(self):
|
||||
"""Return a readable summary for the selected chat message."""
|
||||
item = self.get_item()
|
||||
if item is None:
|
||||
return None
|
||||
composed = self.compose_function(
|
||||
item, self.session.db, self.session.settings,
|
||||
self.session.settings["general"].get("relative_times", False),
|
||||
self.session.settings["general"].get("show_screen_names", False),
|
||||
)
|
||||
return " ".join(composed)
|
||||
|
||||
def get_formatted_message(self):
|
||||
"""Return the text content of the selected chat message."""
|
||||
item = self.get_item()
|
||||
if item is None:
|
||||
return None
|
||||
composed = self.compose_function(
|
||||
item, self.session.db, self.session.settings,
|
||||
self.session.settings["general"].get("relative_times", False),
|
||||
self.session.settings["general"].get("show_screen_names", False),
|
||||
)
|
||||
return composed[1]
|
||||
|
||||
def view_item(self, item=None):
|
||||
"""View the selected chat message in a dialog."""
|
||||
msg = self.get_formatted_message()
|
||||
if not msg:
|
||||
output.speak(_("No message selected."), True)
|
||||
return
|
||||
viewer = blueski_messages.text(title=_("Chat message"), text=msg)
|
||||
viewer.message.ShowModal()
|
||||
viewer.message.Destroy()
|
||||
|
||||
def remove_buffer(self, force=False):
|
||||
"""Allow removing this buffer."""
|
||||
from wxUI import commonMessageDialogs
|
||||
if force == False:
|
||||
dlg = commonMessageDialogs.remove_buffer()
|
||||
else:
|
||||
dlg = widgetUtils.YES
|
||||
if dlg == widgetUtils.YES:
|
||||
if self.name in self.session.db:
|
||||
self.session.db.pop(self.name)
|
||||
return True
|
||||
elif dlg == widgetUtils.NO:
|
||||
return False
|
||||
@@ -0,0 +1,602 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import logging
|
||||
import output
|
||||
from .base import BaseBuffer
|
||||
from wxUI.buffers.blueski import panels as BlueskiPanels
|
||||
|
||||
log = logging.getLogger("controller.buffers.blueski.timeline")
|
||||
|
||||
|
||||
class HomeTimeline(BaseBuffer):
|
||||
"""Discover feed buffer."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(HomeTimeline, self).__init__(*args, **kwargs)
|
||||
self.type = "home_timeline"
|
||||
self.feed_uri = None
|
||||
self.next_cursor = None
|
||||
self.sound = "tweet_received.ogg"
|
||||
|
||||
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 = self.get_max_items()
|
||||
api = self.session._ensure_client()
|
||||
if not self.feed_uri:
|
||||
self.feed_uri = self._resolve_discover_feed(api)
|
||||
try:
|
||||
if self.feed_uri:
|
||||
res = api.app.bsky.feed.get_feed({"feed": self.feed_uri, "limit": count})
|
||||
else:
|
||||
res = api.app.bsky.feed.get_timeline({"limit": count})
|
||||
items = list(getattr(res, "feed", []))
|
||||
self.next_cursor = getattr(res, "cursor", None)
|
||||
except Exception as e:
|
||||
log.error("Error fetching home timeline: %s", e)
|
||||
return 0
|
||||
return self.process_items(items, play_sound)
|
||||
|
||||
def get_more_items(self):
|
||||
if not self.next_cursor:
|
||||
return
|
||||
count = self.get_max_items()
|
||||
api = self.session._ensure_client()
|
||||
try:
|
||||
if self.feed_uri:
|
||||
res = api.app.bsky.feed.get_feed({"feed": self.feed_uri, "limit": count, "cursor": self.next_cursor})
|
||||
else:
|
||||
res = api.app.bsky.feed.get_timeline({"limit": count, "cursor": self.next_cursor})
|
||||
items = list(getattr(res, "feed", []))
|
||||
self.next_cursor = getattr(res, "cursor", None)
|
||||
added = self.process_items(items, play_sound=False)
|
||||
if added:
|
||||
output.speak(_(u"%s items retrieved") % added, True)
|
||||
except Exception as e:
|
||||
log.error("Error fetching more home timeline: %s", e)
|
||||
|
||||
def _resolve_discover_feed(self, api):
|
||||
cached = self.session.db.get("discover_feed_uri")
|
||||
if cached:
|
||||
return cached
|
||||
try:
|
||||
res = api.app.bsky.feed.get_suggested_feeds({"limit": 50})
|
||||
for feed in getattr(res, "feeds", []):
|
||||
dn = getattr(feed, "displayName", "") or getattr(feed, "display_name", "")
|
||||
if "discover" in dn.lower():
|
||||
uri = getattr(feed, "uri", "")
|
||||
self.session.db["discover_feed_uri"] = uri
|
||||
return uri
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
class FollowingTimeline(BaseBuffer):
|
||||
"""Following-only timeline (reverse-chronological)."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(FollowingTimeline, self).__init__(*args, **kwargs)
|
||||
self.type = "following_timeline"
|
||||
self.next_cursor = None
|
||||
self.sound = "tweet_received.ogg"
|
||||
|
||||
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 = self.get_max_items()
|
||||
api = self.session._ensure_client()
|
||||
try:
|
||||
res = api.app.bsky.feed.get_timeline({"limit": count, "algorithm": "reverse-chronological"})
|
||||
items = list(getattr(res, "feed", []))
|
||||
self.next_cursor = getattr(res, "cursor", None)
|
||||
except Exception as e:
|
||||
log.error("Error fetching following timeline: %s", e)
|
||||
return 0
|
||||
return self.process_items(items, play_sound)
|
||||
|
||||
def get_more_items(self):
|
||||
if not self.next_cursor:
|
||||
return
|
||||
count = self.get_max_items()
|
||||
api = self.session._ensure_client()
|
||||
try:
|
||||
res = api.app.bsky.feed.get_timeline({"limit": count, "algorithm": "reverse-chronological", "cursor": self.next_cursor})
|
||||
items = list(getattr(res, "feed", []))
|
||||
self.next_cursor = getattr(res, "cursor", None)
|
||||
added = self.process_items(items, play_sound=False)
|
||||
if added:
|
||||
output.speak(_(u"%s items retrieved") % added, True)
|
||||
except Exception as e:
|
||||
log.error("Error fetching more following timeline: %s", e)
|
||||
|
||||
|
||||
class NotificationBuffer(BaseBuffer):
|
||||
"""Notifications buffer."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
kwargs["compose_func"] = "compose_notification"
|
||||
super(NotificationBuffer, self).__init__(*args, **kwargs)
|
||||
self.type = "notifications"
|
||||
self.sound = "notification_received.ogg"
|
||||
self.next_cursor = None
|
||||
|
||||
def create_buffer(self, parent, name):
|
||||
self.buffer = BlueskiPanels.NotificationPanel(parent, name)
|
||||
self.buffer.session = self.session
|
||||
|
||||
def _hydrate_notifications(self, notifications):
|
||||
"""Fetch subject post text for like/repost notifications."""
|
||||
if not notifications:
|
||||
return notifications
|
||||
|
||||
def g(obj, key, default=None):
|
||||
if isinstance(obj, dict):
|
||||
return obj.get(key, default)
|
||||
return getattr(obj, key, default)
|
||||
|
||||
# Collect URIs for likes/reposts that need subject post text
|
||||
uris_to_fetch = []
|
||||
for notif in notifications:
|
||||
reason = g(notif, "reason", "")
|
||||
if reason in ("like", "repost"):
|
||||
reason_subject = g(notif, "reasonSubject") or g(notif, "reason_subject")
|
||||
if reason_subject and isinstance(reason_subject, str):
|
||||
uris_to_fetch.append(reason_subject)
|
||||
|
||||
if not uris_to_fetch:
|
||||
return notifications
|
||||
|
||||
# Fetch posts in batch
|
||||
posts_map = {}
|
||||
try:
|
||||
api = self.session._ensure_client()
|
||||
if api and uris_to_fetch:
|
||||
# getPosts accepts up to 25 URIs at a time
|
||||
for i in range(0, len(uris_to_fetch), 25):
|
||||
batch = uris_to_fetch[i:i+25]
|
||||
res = api.app.bsky.feed.get_posts({"uris": batch})
|
||||
for post in getattr(res, "posts", []):
|
||||
uri = g(post, "uri")
|
||||
if uri:
|
||||
record = g(post, "record", {})
|
||||
text = g(record, "text", "")
|
||||
posts_map[uri] = text
|
||||
except Exception as e:
|
||||
log.error("Error fetching subject posts for notifications: %s", e)
|
||||
|
||||
# Attach subject post text to notifications
|
||||
enriched = []
|
||||
for notif in notifications:
|
||||
reason = g(notif, "reason", "")
|
||||
if reason in ("like", "repost"):
|
||||
reason_subject = g(notif, "reasonSubject") or g(notif, "reason_subject")
|
||||
if reason_subject and reason_subject in posts_map:
|
||||
# Create a modified notification with subject post text
|
||||
if isinstance(notif, dict):
|
||||
notif = dict(notif)
|
||||
notif["_subject_text"] = posts_map[reason_subject]
|
||||
else:
|
||||
# For ATProto model objects, add as attribute
|
||||
try:
|
||||
notif._subject_text = posts_map[reason_subject]
|
||||
except AttributeError:
|
||||
pass
|
||||
enriched.append(notif)
|
||||
|
||||
return enriched
|
||||
|
||||
def start_stream(self, mandatory=False, play_sound=True):
|
||||
count = self.get_max_items()
|
||||
api = self.session._ensure_client()
|
||||
if not api:
|
||||
return 0
|
||||
try:
|
||||
res = api.app.bsky.notification.list_notifications({"limit": count})
|
||||
notifications = list(getattr(res, "notifications", []))
|
||||
self.next_cursor = getattr(res, "cursor", None)
|
||||
if not notifications:
|
||||
return 0
|
||||
notifications = self._hydrate_notifications(notifications)
|
||||
return self.process_items(notifications, play_sound)
|
||||
except Exception as e:
|
||||
log.error("Error fetching notifications: %s", e)
|
||||
return 0
|
||||
|
||||
def get_more_items(self):
|
||||
if not self.next_cursor:
|
||||
return
|
||||
count = self.get_max_items()
|
||||
api = self.session._ensure_client()
|
||||
if not api:
|
||||
return
|
||||
try:
|
||||
res = api.app.bsky.notification.list_notifications({"limit": count, "cursor": self.next_cursor})
|
||||
notifications = list(getattr(res, "notifications", []))
|
||||
self.next_cursor = getattr(res, "cursor", None)
|
||||
notifications = self._hydrate_notifications(notifications)
|
||||
added = self.process_items(notifications, play_sound=False)
|
||||
if added:
|
||||
output.speak(_(u"%s items retrieved") % added, True)
|
||||
except Exception as e:
|
||||
log.error("Error fetching more notifications: %s", e)
|
||||
|
||||
def add_new_item(self, notification):
|
||||
notifications = self._hydrate_notifications([notification])
|
||||
return self.process_items(notifications, play_sound=True)
|
||||
|
||||
|
||||
class Conversation(BaseBuffer):
|
||||
"""Thread/conversation view."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(Conversation, self).__init__(*args, **kwargs)
|
||||
self.type = "conversation"
|
||||
self.root_uri = kwargs.get("uri")
|
||||
self.sound = "search_updated.ogg"
|
||||
|
||||
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.root_uri:
|
||||
return 0
|
||||
api = self.session._ensure_client()
|
||||
try:
|
||||
res = api.app.bsky.feed.get_post_thread({"uri": self.root_uri, "depth": 100, "parentHeight": 100})
|
||||
thread = getattr(res, "thread", None)
|
||||
if not thread:
|
||||
return 0
|
||||
|
||||
def g(obj, key, default=None):
|
||||
return obj.get(key, default) if isinstance(obj, dict) else getattr(obj, key, default)
|
||||
|
||||
final_items = []
|
||||
# Add ancestors
|
||||
ancestors = []
|
||||
parent = g(thread, "parent")
|
||||
while parent:
|
||||
ppost = g(parent, "post")
|
||||
if ppost:
|
||||
ancestors.insert(0, ppost)
|
||||
parent = g(parent, "parent")
|
||||
final_items.extend(ancestors)
|
||||
|
||||
# Traverse thread
|
||||
def traverse(node):
|
||||
if not node:
|
||||
return
|
||||
post = g(node, "post")
|
||||
if post:
|
||||
final_items.append(post)
|
||||
for r in (g(node, "replies") or []):
|
||||
traverse(r)
|
||||
|
||||
traverse(thread)
|
||||
self.session.db[self.name] = []
|
||||
self.buffer.list.clear()
|
||||
# Don't use process_items() because it applies reverse logic.
|
||||
# Conversations should always be chronological (oldest first).
|
||||
return self._add_items_chronological(final_items, play_sound)
|
||||
except Exception as e:
|
||||
log.error("Error fetching thread: %s", e)
|
||||
return 0
|
||||
|
||||
def _add_items_chronological(self, items, play_sound=True):
|
||||
"""Add items in chronological order (oldest first) without reverse logic."""
|
||||
if not items:
|
||||
return 0
|
||||
|
||||
safe = True
|
||||
relative_times = self.session.settings["general"].get("relative_times", False)
|
||||
show_screen_names = self.session.settings["general"].get("show_screen_names", False)
|
||||
|
||||
for item in items:
|
||||
self.session.db[self.name].append(item)
|
||||
post = self.compose_function(item, self.session.db, self.session.settings,
|
||||
relative_times=relative_times,
|
||||
show_screen_names=show_screen_names,
|
||||
safe=safe)
|
||||
self.buffer.list.insert_item(False, *post)
|
||||
|
||||
# Select the root post (first item after ancestors, or just the first)
|
||||
total = self.buffer.list.get_count()
|
||||
if total > 0:
|
||||
self.buffer.list.select_item(0)
|
||||
|
||||
if play_sound and self.sound and not self.session.settings["sound"]["session_mute"]:
|
||||
self.session.sound.play(self.sound)
|
||||
|
||||
return len(items)
|
||||
|
||||
|
||||
class LikesBuffer(BaseBuffer):
|
||||
"""User's liked posts."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(LikesBuffer, self).__init__(*args, **kwargs)
|
||||
self.type = "likes"
|
||||
self.next_cursor = None
|
||||
self.sound = "favourite.ogg"
|
||||
|
||||
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 = self.get_max_items()
|
||||
api = self.session._ensure_client()
|
||||
try:
|
||||
res = api.app.bsky.feed.get_actor_likes({"actor": api.me.did, "limit": count})
|
||||
items = list(getattr(res, "feed", None) or getattr(res, "items", None) or [])
|
||||
self.next_cursor = getattr(res, "cursor", None)
|
||||
except Exception as e:
|
||||
log.error("Error fetching likes: %s", e)
|
||||
return 0
|
||||
return self.process_items(items, play_sound)
|
||||
|
||||
def get_more_items(self):
|
||||
if not self.next_cursor:
|
||||
return
|
||||
count = self.get_max_items()
|
||||
api = self.session._ensure_client()
|
||||
if not api:
|
||||
return
|
||||
try:
|
||||
res = api.app.bsky.feed.get_actor_likes({"actor": api.me.did, "limit": count, "cursor": self.next_cursor})
|
||||
items = list(getattr(res, "feed", None) or getattr(res, "items", None) or [])
|
||||
self.next_cursor = getattr(res, "cursor", None)
|
||||
added = self.process_items(items, play_sound=False)
|
||||
if added:
|
||||
output.speak(_(u"%s items retrieved") % added, True)
|
||||
except Exception as e:
|
||||
log.error("Error fetching more likes: %s", e)
|
||||
|
||||
|
||||
class MentionsBuffer(BaseBuffer):
|
||||
"""Mentions, replies and quotes."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
kwargs["compose_func"] = "compose_notification"
|
||||
super(MentionsBuffer, self).__init__(*args, **kwargs)
|
||||
self.type = "mentions"
|
||||
self.sound = "mention_received.ogg"
|
||||
self.next_cursor = None
|
||||
|
||||
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 = self.get_max_items()
|
||||
api = self.session._ensure_client()
|
||||
if not api:
|
||||
return 0
|
||||
try:
|
||||
res = api.app.bsky.notification.list_notifications({"limit": count})
|
||||
notifications = getattr(res, "notifications", [])
|
||||
self.next_cursor = getattr(res, "cursor", None)
|
||||
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 as e:
|
||||
log.error("Error fetching mentions: %s", e)
|
||||
return 0
|
||||
|
||||
def get_more_items(self):
|
||||
if not self.next_cursor:
|
||||
return
|
||||
count = self.get_max_items()
|
||||
api = self.session._ensure_client()
|
||||
if not api:
|
||||
return
|
||||
try:
|
||||
res = api.app.bsky.notification.list_notifications({"limit": count, "cursor": self.next_cursor})
|
||||
notifications = getattr(res, "notifications", [])
|
||||
self.next_cursor = getattr(res, "cursor", None)
|
||||
mentions = [n for n in notifications if getattr(n, "reason", "") in ("mention", "reply", "quote")]
|
||||
if mentions:
|
||||
added = self.process_items(mentions, play_sound=False)
|
||||
if added:
|
||||
output.speak(_(u"%s items retrieved") % added, True)
|
||||
except Exception as e:
|
||||
log.error("Error fetching more mentions: %s", e)
|
||||
|
||||
def add_new_item(self, notification):
|
||||
if getattr(notification, "reason", "") in ("mention", "reply", "quote"):
|
||||
return self.process_items([notification], play_sound=True)
|
||||
return 0
|
||||
|
||||
|
||||
class SentBuffer(BaseBuffer):
|
||||
"""User's sent posts."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(SentBuffer, self).__init__(*args, **kwargs)
|
||||
self.type = "sent"
|
||||
self.next_cursor = None
|
||||
|
||||
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 = self.get_max_items()
|
||||
api = self.session._ensure_client()
|
||||
if not api or not api.me:
|
||||
return 0
|
||||
try:
|
||||
res = api.app.bsky.feed.get_author_feed({"actor": api.me.did, "limit": count, "filter": "posts_no_replies"})
|
||||
items = list(getattr(res, "feed", []))
|
||||
self.next_cursor = getattr(res, "cursor", None)
|
||||
if not items:
|
||||
return 0
|
||||
return self.process_items(items, play_sound)
|
||||
except Exception as e:
|
||||
log.error("Error fetching sent posts: %s", e)
|
||||
return 0
|
||||
|
||||
def get_more_items(self):
|
||||
if not self.next_cursor:
|
||||
return
|
||||
count = self.get_max_items()
|
||||
api = self.session._ensure_client()
|
||||
if not api or not api.me:
|
||||
return
|
||||
try:
|
||||
res = api.app.bsky.feed.get_author_feed({"actor": api.me.did, "limit": count, "filter": "posts_no_replies", "cursor": self.next_cursor})
|
||||
items = list(getattr(res, "feed", []))
|
||||
self.next_cursor = getattr(res, "cursor", None)
|
||||
added = self.process_items(items, play_sound=False)
|
||||
if added:
|
||||
output.speak(_(u"%s items retrieved") % added, True)
|
||||
except Exception as e:
|
||||
log.error("Error fetching more sent posts: %s", e)
|
||||
|
||||
|
||||
class UserTimeline(BaseBuffer):
|
||||
"""Timeline for a specific user."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.actor = kwargs.get("actor")
|
||||
self.handle = kwargs.get("handle")
|
||||
super(UserTimeline, self).__init__(*args, **kwargs)
|
||||
self.type = "user_timeline"
|
||||
self.next_cursor = None
|
||||
self._resolved_actor = None
|
||||
self.sound = "tweet_timeline.ogg"
|
||||
|
||||
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.actor:
|
||||
return 0
|
||||
count = self.get_max_items()
|
||||
actor = self.actor.strip().lstrip("@") if isinstance(self.actor, str) else self.actor
|
||||
api = self.session._ensure_client()
|
||||
if not api:
|
||||
return 0
|
||||
try:
|
||||
if isinstance(actor, str) and not actor.startswith("did:"):
|
||||
profile = self.session.get_profile(actor)
|
||||
if profile:
|
||||
did = profile.get("did") if isinstance(profile, dict) else getattr(profile, "did", None)
|
||||
if did:
|
||||
actor = did
|
||||
self._resolved_actor = actor
|
||||
res = api.app.bsky.feed.get_author_feed({"actor": actor, "limit": count})
|
||||
items = list(getattr(res, "feed", []) or [])
|
||||
self.next_cursor = getattr(res, "cursor", None)
|
||||
except Exception as e:
|
||||
log.error("Error fetching user timeline: %s", e)
|
||||
return 0
|
||||
return self.process_items(items, play_sound)
|
||||
|
||||
def get_more_items(self):
|
||||
if not self.next_cursor or not self._resolved_actor:
|
||||
return
|
||||
count = self.get_max_items()
|
||||
api = self.session._ensure_client()
|
||||
if not api:
|
||||
return
|
||||
try:
|
||||
res = api.app.bsky.feed.get_author_feed({"actor": self._resolved_actor, "limit": count, "cursor": self.next_cursor})
|
||||
items = list(getattr(res, "feed", []) or [])
|
||||
self.next_cursor = getattr(res, "cursor", None)
|
||||
added = self.process_items(items, play_sound=False)
|
||||
if added:
|
||||
output.speak(_(u"%s items retrieved") % added, True)
|
||||
except Exception as e:
|
||||
log.error("Error fetching more user timeline: %s", e)
|
||||
|
||||
def remove_buffer(self, force=False):
|
||||
if not force:
|
||||
from wxUI import commonMessageDialogs
|
||||
import widgetUtils
|
||||
if commonMessageDialogs.remove_buffer() != widgetUtils.YES:
|
||||
return False
|
||||
self.session.db.pop(self.name, None)
|
||||
timelines = self.session.settings["other_buffers"].get("timelines") or []
|
||||
if isinstance(timelines, str):
|
||||
timelines = [t for t in timelines.split(",") if t]
|
||||
for key in (self.actor or "", self.handle or ""):
|
||||
if key in timelines:
|
||||
timelines.remove(key)
|
||||
self.session.settings["other_buffers"]["timelines"] = timelines
|
||||
self.session.settings.write()
|
||||
return True
|
||||
|
||||
|
||||
class SearchBuffer(BaseBuffer):
|
||||
"""Search results buffer."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.search_query = kwargs.pop("query", "")
|
||||
super(SearchBuffer, self).__init__(*args, **kwargs)
|
||||
self.type = "search"
|
||||
self.next_cursor = None
|
||||
self.sound = "search_updated.ogg"
|
||||
|
||||
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 = self.get_max_items()
|
||||
api = self.session._ensure_client()
|
||||
if not api:
|
||||
return 0
|
||||
try:
|
||||
res = api.app.bsky.feed.search_posts({"q": self.search_query, "limit": count})
|
||||
posts = list(getattr(res, "posts", []))
|
||||
self.next_cursor = getattr(res, "cursor", None)
|
||||
if not posts:
|
||||
return 0
|
||||
self.session.db[self.name] = []
|
||||
self.buffer.list.clear()
|
||||
return self.process_items(posts, play_sound)
|
||||
except Exception as e:
|
||||
log.error("Error searching posts: %s", e)
|
||||
return 0
|
||||
|
||||
def get_more_items(self):
|
||||
if not self.next_cursor or not self.search_query:
|
||||
return
|
||||
count = self.get_max_items()
|
||||
api = self.session._ensure_client()
|
||||
if not api:
|
||||
return
|
||||
try:
|
||||
res = api.app.bsky.feed.search_posts({"q": self.search_query, "limit": count, "cursor": self.next_cursor})
|
||||
posts = list(getattr(res, "posts", []))
|
||||
self.next_cursor = getattr(res, "cursor", None)
|
||||
added = self.process_items(posts, play_sound=False)
|
||||
if added:
|
||||
output.speak(_(u"%s items retrieved") % added, True)
|
||||
except Exception as e:
|
||||
log.error("Error fetching more search results: %s", e)
|
||||
|
||||
def remove_buffer(self, force=False):
|
||||
if not force:
|
||||
from wxUI import commonMessageDialogs
|
||||
import widgetUtils
|
||||
if commonMessageDialogs.remove_buffer() != widgetUtils.YES:
|
||||
return False
|
||||
self.session.db.pop(self.name, None)
|
||||
searches = self.session.settings["other_buffers"].get("searches") or []
|
||||
if isinstance(searches, str):
|
||||
searches = [s for s in searches.split(",") if s]
|
||||
if self.search_query in searches:
|
||||
searches.remove(self.search_query)
|
||||
self.session.settings["other_buffers"]["searches"] = searches
|
||||
self.session.settings.write()
|
||||
return True
|
||||
@@ -0,0 +1,289 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import logging
|
||||
import output
|
||||
from .base import BaseBuffer
|
||||
from wxUI.buffers.blueski import panels as BlueskiPanels
|
||||
from sessions.blueski import compose
|
||||
|
||||
log = logging.getLogger("controller.buffers.blueski.user")
|
||||
|
||||
class UserBuffer(BaseBuffer):
|
||||
def __init__(self, *args, **kwargs):
|
||||
# We need compose_user for this buffer
|
||||
kwargs["compose_func"] = "compose_user"
|
||||
super(UserBuffer, self).__init__(*args, **kwargs)
|
||||
self.type = "user"
|
||||
self.next_cursor = None
|
||||
self.sound = "new_event.ogg"
|
||||
|
||||
def create_buffer(self, parent, name):
|
||||
self.buffer = BlueskiPanels.UserPanel(parent, name)
|
||||
self.buffer.session = self.session
|
||||
|
||||
def start_stream(self, mandatory=False, play_sound=True):
|
||||
api_method = self.kwargs.get("api_method")
|
||||
if not api_method: return 0
|
||||
|
||||
count = self.get_max_items()
|
||||
actor = (
|
||||
self.kwargs.get("actor")
|
||||
or self.kwargs.get("did")
|
||||
or self.kwargs.get("handle")
|
||||
or self.kwargs.get("id")
|
||||
)
|
||||
|
||||
try:
|
||||
if api_method in ("get_followers", "get_follows"):
|
||||
res = getattr(self.session, api_method)(actor=actor, limit=count)
|
||||
else:
|
||||
res = getattr(self.session, api_method)(limit=count)
|
||||
items = self._hydrate_profiles(res.get("items", []) or [])
|
||||
self.next_cursor = res.get("cursor")
|
||||
return self.process_items(items, play_sound)
|
||||
except Exception as e:
|
||||
log.error("Error fetching user list for %s: %s", self.name, e)
|
||||
return 0
|
||||
|
||||
def get_more_items(self):
|
||||
api_method = self.kwargs.get("api_method")
|
||||
if not api_method or not self.next_cursor:
|
||||
return
|
||||
|
||||
count = self.get_max_items()
|
||||
actor = (
|
||||
self.kwargs.get("actor")
|
||||
or self.kwargs.get("did")
|
||||
or self.kwargs.get("handle")
|
||||
or self.kwargs.get("id")
|
||||
)
|
||||
try:
|
||||
if api_method in ("get_followers", "get_follows"):
|
||||
res = getattr(self.session, api_method)(actor=actor, limit=count, cursor=self.next_cursor)
|
||||
else:
|
||||
res = getattr(self.session, api_method)(limit=count, cursor=self.next_cursor)
|
||||
items = self._hydrate_profiles(res.get("items", []) or [])
|
||||
self.next_cursor = res.get("cursor")
|
||||
added = self.process_items(items, play_sound=False)
|
||||
if added:
|
||||
output.speak(_(u"%s items retrieved") % (str(added)), True)
|
||||
except Exception as e:
|
||||
log.error("Error fetching more user list items for %s: %s", self.name, e)
|
||||
|
||||
def _hydrate_profiles(self, items):
|
||||
if not items:
|
||||
return []
|
||||
|
||||
def g(obj, key, default=None):
|
||||
if isinstance(obj, dict):
|
||||
return obj.get(key, default)
|
||||
return getattr(obj, key, default)
|
||||
|
||||
def resolve_profile(obj):
|
||||
if g(obj, "handle") or g(obj, "did"):
|
||||
return obj
|
||||
for key in ("subject", "actor", "profile", "user"):
|
||||
nested = g(obj, key)
|
||||
if nested and (g(nested, "handle") or g(nested, "did")):
|
||||
return nested
|
||||
return obj
|
||||
|
||||
actors = []
|
||||
for item in items:
|
||||
profile = resolve_profile(item)
|
||||
did = g(profile, "did")
|
||||
handle = g(profile, "handle")
|
||||
if did:
|
||||
actors.append(did)
|
||||
elif handle:
|
||||
actors.append(handle)
|
||||
|
||||
if not actors:
|
||||
return items
|
||||
|
||||
profiles = []
|
||||
if actors and hasattr(self.session, "get_profiles"):
|
||||
try:
|
||||
res = self.session.get_profiles(actors)
|
||||
profiles = res.get("items", []) or []
|
||||
except Exception:
|
||||
profiles = []
|
||||
# If batch profiles lack counts, hydrate with detailed profiles.
|
||||
if hasattr(self.session, "get_profile"):
|
||||
def counts_missing(profile_obj):
|
||||
p1 = g(profile_obj, "followersCount") or g(profile_obj, "followers_count")
|
||||
p2 = g(profile_obj, "followsCount") or g(profile_obj, "follows_count")
|
||||
p3 = g(profile_obj, "postsCount") or g(profile_obj, "posts_count")
|
||||
if p1 is None and p2 is None and p3 is None:
|
||||
return True
|
||||
return (p1 or 0) == 0 and (p2 or 0) == 0 and (p3 or 0) == 0
|
||||
|
||||
if not profiles:
|
||||
for actor in actors:
|
||||
try:
|
||||
p = self.session.get_profile(actor)
|
||||
if p:
|
||||
profiles.append(p)
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
for idx, p in enumerate(profiles):
|
||||
if counts_missing(p):
|
||||
did = g(p, "did") or g(p, "handle")
|
||||
if not did:
|
||||
continue
|
||||
try:
|
||||
detailed = self.session.get_profile(did)
|
||||
if detailed:
|
||||
profiles[idx] = detailed
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
profile_map = {}
|
||||
for p in profiles:
|
||||
did = g(p, "did")
|
||||
handle = g(p, "handle")
|
||||
if did:
|
||||
profile_map[did] = p
|
||||
if handle and handle not in profile_map:
|
||||
profile_map[handle] = p
|
||||
|
||||
def needs_replace(item, profile):
|
||||
if profile is None:
|
||||
return False
|
||||
base = resolve_profile(item)
|
||||
f1 = g(base, "followersCount") or g(base, "followers_count")
|
||||
f2 = g(base, "followsCount") or g(base, "follows_count")
|
||||
f3 = g(base, "postsCount") or g(base, "posts_count")
|
||||
p1 = g(profile, "followersCount") or g(profile, "followers_count")
|
||||
p2 = g(profile, "followsCount") or g(profile, "follows_count")
|
||||
p3 = g(profile, "postsCount") or g(profile, "posts_count")
|
||||
if f1 is None and f2 is None and f3 is None:
|
||||
return True
|
||||
if (f1 or 0) == 0 and (f2 or 0) == 0 and (f3 or 0) == 0:
|
||||
return (p1 or 0) != 0 or (p2 or 0) != 0 or (p3 or 0) != 0
|
||||
return False
|
||||
|
||||
enriched = []
|
||||
for item in items:
|
||||
base = resolve_profile(item)
|
||||
did = g(base, "did")
|
||||
handle = g(base, "handle")
|
||||
profile = profile_map.get(did) or profile_map.get(handle)
|
||||
if needs_replace(item, profile):
|
||||
enriched.append(profile)
|
||||
else:
|
||||
enriched.append(item)
|
||||
return enriched
|
||||
|
||||
class FollowersBuffer(UserBuffer):
|
||||
def __init__(self, *args, **kwargs):
|
||||
kwargs["api_method"] = "get_followers"
|
||||
super(FollowersBuffer, self).__init__(*args, **kwargs)
|
||||
self.sound = "update_followers.ogg"
|
||||
|
||||
def remove_buffer(self, force=False):
|
||||
if not force:
|
||||
from wxUI import commonMessageDialogs
|
||||
import widgetUtils
|
||||
dlg = commonMessageDialogs.remove_buffer()
|
||||
if dlg != widgetUtils.YES:
|
||||
return False
|
||||
try:
|
||||
self.session.db.pop(self.name, None)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
key = self.kwargs.get("actor") or self.kwargs.get("handle") or self.kwargs.get("id")
|
||||
timelines = self.session.settings["other_buffers"].get("followers_timelines") or []
|
||||
if isinstance(timelines, str):
|
||||
timelines = [t for t in timelines.split(",") if t]
|
||||
if key in timelines:
|
||||
timelines.remove(key)
|
||||
self.session.settings["other_buffers"]["followers_timelines"] = timelines
|
||||
self.session.settings.write()
|
||||
except Exception as e:
|
||||
log.error("Error updating Bluesky followers timelines settings: %s", e)
|
||||
return True
|
||||
|
||||
class FollowingBuffer(UserBuffer):
|
||||
def __init__(self, *args, **kwargs):
|
||||
kwargs["api_method"] = "get_follows"
|
||||
super(FollowingBuffer, self).__init__(*args, **kwargs)
|
||||
self.sound = "update_followers.ogg"
|
||||
|
||||
def remove_buffer(self, force=False):
|
||||
if not force:
|
||||
from wxUI import commonMessageDialogs
|
||||
import widgetUtils
|
||||
dlg = commonMessageDialogs.remove_buffer()
|
||||
if dlg != widgetUtils.YES:
|
||||
return False
|
||||
try:
|
||||
self.session.db.pop(self.name, None)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
key = self.kwargs.get("actor") or self.kwargs.get("handle") or self.kwargs.get("id")
|
||||
timelines = self.session.settings["other_buffers"].get("following_timelines") or []
|
||||
if isinstance(timelines, str):
|
||||
timelines = [t for t in timelines.split(",") if t]
|
||||
if key in timelines:
|
||||
timelines.remove(key)
|
||||
self.session.settings["other_buffers"]["following_timelines"] = timelines
|
||||
self.session.settings.write()
|
||||
except Exception as e:
|
||||
log.error("Error updating Bluesky following timelines settings: %s", e)
|
||||
return True
|
||||
|
||||
class BlocksBuffer(UserBuffer):
|
||||
def __init__(self, *args, **kwargs):
|
||||
kwargs["api_method"] = "get_blocks"
|
||||
super(BlocksBuffer, self).__init__(*args, **kwargs)
|
||||
|
||||
|
||||
class PostUserListBuffer(UserBuffer):
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.post_uri = kwargs.get("post_uri")
|
||||
self.api_method = kwargs.get("api_method")
|
||||
super(PostUserListBuffer, self).__init__(*args, **kwargs)
|
||||
self.type = "post_user_list"
|
||||
|
||||
def start_stream(self, mandatory=False, play_sound=True):
|
||||
if not self.api_method or not self.post_uri:
|
||||
return 0
|
||||
count = self.get_max_items()
|
||||
try:
|
||||
res = getattr(self.session, self.api_method)(self.post_uri, limit=count)
|
||||
items = res.get("items", [])
|
||||
self.next_cursor = res.get("cursor")
|
||||
return self.process_items(items, play_sound)
|
||||
except Exception as e:
|
||||
log.error("Error fetching post user list for %s: %s", self.name, e)
|
||||
return 0
|
||||
|
||||
def get_more_items(self):
|
||||
if not self.api_method or not self.post_uri or not self.next_cursor:
|
||||
return
|
||||
count = self.get_max_items()
|
||||
try:
|
||||
res = getattr(self.session, self.api_method)(self.post_uri, limit=count, cursor=self.next_cursor)
|
||||
items = res.get("items", [])
|
||||
self.next_cursor = res.get("cursor")
|
||||
added = self.process_items(items, play_sound=False)
|
||||
if added:
|
||||
output.speak(_(u"%s items retrieved") % (str(added)), True)
|
||||
except Exception as e:
|
||||
log.error("Error fetching more post user list items for %s: %s", self.name, e)
|
||||
|
||||
def remove_buffer(self, force=False):
|
||||
if not force:
|
||||
from wxUI import commonMessageDialogs
|
||||
import widgetUtils
|
||||
dlg = commonMessageDialogs.remove_buffer()
|
||||
if dlg != widgetUtils.YES:
|
||||
return False
|
||||
try:
|
||||
self.session.db.pop(self.name, None)
|
||||
except Exception:
|
||||
pass
|
||||
return True
|
||||
@@ -5,6 +5,7 @@ import logging
|
||||
import webbrowser
|
||||
import wx
|
||||
import requests
|
||||
import asyncio
|
||||
import keystrokeEditor
|
||||
import sessions
|
||||
import widgetUtils
|
||||
@@ -25,6 +26,7 @@ from mysc import localization
|
||||
from mysc.thread_utils import call_threaded
|
||||
from mysc.repeating_timer import RepeatingTimer
|
||||
from controller.mastodon import handler as MastodonHandler
|
||||
from controller.blueski import handler as BlueskiHandler # Added import
|
||||
from . import settings, userAlias
|
||||
|
||||
log = logging.getLogger("mainController")
|
||||
@@ -93,6 +95,17 @@ class Controller(object):
|
||||
[results.append(self.search_buffer(i.name, i.account)) for i in buffers if i.account == account and (i.type != "account")]
|
||||
return results
|
||||
|
||||
def get_handler(self, type):
|
||||
"""Return the controller handler for a given session type."""
|
||||
try:
|
||||
if type == "mastodon":
|
||||
return MastodonHandler.Handler()
|
||||
if type == "blueski":
|
||||
return BlueskiHandler.Handler()
|
||||
except Exception:
|
||||
log.exception("Error creating handler for type %s", type)
|
||||
return None
|
||||
|
||||
def bind_other_events(self):
|
||||
""" Binds the local application events with their functions."""
|
||||
log.debug("Binding other application events...")
|
||||
@@ -107,6 +120,7 @@ class Controller(object):
|
||||
pub.subscribe(self.invisible_shorcuts_changed, "invisible-shorcuts-changed")
|
||||
pub.subscribe(self.create_account_buffer, "core.create_account")
|
||||
pub.subscribe(self.change_buffer_title, "core.change_buffer_title")
|
||||
pub.subscribe(self.handle_compose_dialog_send, "compose_dialog.send_post") # For new compose dialog
|
||||
|
||||
# Mastodon specific events.
|
||||
pub.subscribe(self.mastodon_new_item, "mastodon.new_item")
|
||||
@@ -114,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)
|
||||
@@ -191,10 +208,12 @@ class Controller(object):
|
||||
|
||||
def get_handler(self, type):
|
||||
handler = self.handlers.get(type)
|
||||
if handler == None:
|
||||
if handler is None:
|
||||
if type == "mastodon":
|
||||
handler = MastodonHandler.Handler()
|
||||
self.handlers[type]=handler
|
||||
elif type == "blueski":
|
||||
handler = BlueskiHandler.Handler()
|
||||
self.handlers[type] = handler
|
||||
return handler
|
||||
|
||||
def __init__(self):
|
||||
@@ -204,7 +223,7 @@ class Controller(object):
|
||||
# main window
|
||||
self.view = view.mainFrame()
|
||||
# buffers list.
|
||||
self.buffers = []
|
||||
self.buffers: list[buffers.base.Buffer] = [] # Added type hint
|
||||
self.started = False
|
||||
# accounts list.
|
||||
self.accounts = []
|
||||
@@ -235,14 +254,26 @@ class Controller(object):
|
||||
for i in sessions.sessions:
|
||||
log.debug("Working on session %s" % (i,))
|
||||
if sessions.sessions[i].is_logged == False:
|
||||
self.create_ignored_session_buffer(sessions.sessions[i])
|
||||
continue
|
||||
# Valid types currently are mastodon (Work in progress)
|
||||
# More can be added later.
|
||||
valid_session_types = ["mastodon"]
|
||||
if sessions.sessions[i].session_id in config.app["sessions"]["ignored_sessions"]:
|
||||
self.create_ignored_session_buffer(sessions.sessions[i])
|
||||
continue
|
||||
# Try auto-login for sessions if credentials exist
|
||||
try:
|
||||
sessions.sessions[i].login()
|
||||
except Exception:
|
||||
log.exception("Auto-login attempt failed for session %s", i)
|
||||
if sessions.sessions[i].is_logged == False:
|
||||
self.create_ignored_session_buffer(sessions.sessions[i])
|
||||
continue
|
||||
# Supported session types
|
||||
valid_session_types = ["mastodon", "blueski"]
|
||||
if sessions.sessions[i].type in valid_session_types:
|
||||
handler = self.get_handler(type=sessions.sessions[i].type)
|
||||
handler.create_buffers(sessions.sessions[i], controller=self)
|
||||
try:
|
||||
handler = self.get_handler(type=sessions.sessions[i].type)
|
||||
if handler is not None:
|
||||
handler.create_buffers(sessions.sessions[i], controller=self)
|
||||
except Exception:
|
||||
log.exception("Error creating buffers for session %s (%s)", i, sessions.sessions[i].type)
|
||||
log.debug("Setting updates to buffers every %d seconds..." % (60*config.app["app-settings"]["update_period"],))
|
||||
self.update_buffers_function = RepeatingTimer(60*config.app["app-settings"]["update_period"], self.update_buffers)
|
||||
self.update_buffers_function.start()
|
||||
@@ -251,29 +282,83 @@ class Controller(object):
|
||||
""" Starts all buffer objects. Loads their items."""
|
||||
for i in sessions.sessions:
|
||||
if sessions.sessions[i].is_logged == False: continue
|
||||
self.start_buffers(sessions.sessions[i])
|
||||
self.set_buffer_positions(sessions.sessions[i])
|
||||
if hasattr(sessions.sessions[i], "start_streaming"):
|
||||
sessions.sessions[i].start_streaming()
|
||||
if config.app["app-settings"]["play_ready_sound"] == True:
|
||||
call_threaded(self._start_session_buffers, sessions.sessions[i])
|
||||
if len(sessions.sessions) > 0 and config.app["app-settings"]["play_ready_sound"] == True:
|
||||
sessions.sessions[list(sessions.sessions.keys())[0]].sound.play("ready.ogg")
|
||||
if config.app["app-settings"]["speak_ready_msg"] == True:
|
||||
output.speak(_(u"Ready"))
|
||||
self.started = True
|
||||
if len(self.accounts) > 0:
|
||||
b = self.get_first_buffer(self.accounts[0])
|
||||
self.menubar_current_handler = b.session.type
|
||||
self.update_menus(handler=self.get_handler(b.session.type))
|
||||
|
||||
def _start_session_buffers(self, session):
|
||||
"""Helper to start buffers for a session in a background thread."""
|
||||
try:
|
||||
self.start_buffers(session)
|
||||
self.set_buffer_positions(session)
|
||||
if hasattr(session, "start_streaming"):
|
||||
session.start_streaming()
|
||||
except Exception:
|
||||
log.exception("Error starting buffers for session %s", session.session_id)
|
||||
|
||||
def create_ignored_session_buffer(self, session):
|
||||
pub.sendMessage("core.create_account", name=session.get_name(), session_id=session.session_id)
|
||||
|
||||
def login_account(self, session_id):
|
||||
session = None
|
||||
for i in sessions.sessions:
|
||||
if sessions.sessions[i].session_id == session_id: session = sessions.sessions[i]
|
||||
session.login()
|
||||
if sessions.sessions[i].session_id == session_id:
|
||||
session = sessions.sessions[i]
|
||||
break
|
||||
if not session:
|
||||
return
|
||||
|
||||
old_name = session.get_name()
|
||||
try:
|
||||
session.login()
|
||||
except Exception as e:
|
||||
log.exception("Login failed for session %s", session_id)
|
||||
output.speak(_("Login failed for {0}: {1}").format(old_name, str(e)), True)
|
||||
return
|
||||
|
||||
if not session.logged:
|
||||
output.speak(_("Login failed for {0}. Please check your credentials.").format(old_name), True)
|
||||
return
|
||||
|
||||
new_name = session.get_name()
|
||||
if old_name != new_name:
|
||||
log.info(f"Account name changed from {old_name} to {new_name} after login")
|
||||
if self.current_account == old_name:
|
||||
self.current_account = new_name
|
||||
if old_name in self.accounts:
|
||||
idx = self.accounts.index(old_name)
|
||||
self.accounts[idx] = new_name
|
||||
else:
|
||||
self.accounts.append(new_name)
|
||||
|
||||
# Update root buffer name and account
|
||||
for b in self.buffers:
|
||||
if b.account == old_name:
|
||||
b.account = new_name
|
||||
if hasattr(b, "buffer"):
|
||||
b.buffer.account = new_name
|
||||
# If this is the root node, its name matches old_name (e.g. "Bluesky")
|
||||
if b.name == old_name:
|
||||
b.name = new_name
|
||||
if hasattr(b, "buffer"):
|
||||
b.buffer.name = new_name
|
||||
|
||||
# Update tree node label
|
||||
self.change_buffer_title(old_name, old_name, new_name)
|
||||
|
||||
handler = self.get_handler(type=session.type)
|
||||
if handler != None and hasattr(handler, "create_buffers"):
|
||||
handler.create_buffers(session=session, controller=self, createAccounts=False)
|
||||
try:
|
||||
handler.create_buffers(session=session, controller=self, createAccounts=False)
|
||||
except Exception:
|
||||
log.exception("Error creating buffers after login for session %s (%s)", session.session_id, session.type)
|
||||
self.start_buffers(session)
|
||||
if hasattr(session, "start_streaming"):
|
||||
session.start_streaming()
|
||||
@@ -287,31 +372,91 @@ class Controller(object):
|
||||
self.view.add_buffer(account.buffer , name=name)
|
||||
|
||||
def create_buffer(self, buffer_type="baseBuffer", session_type="twitter", buffer_title="", parent_tab=None, start=False, kwargs={}):
|
||||
# Copy kwargs to avoid mutating a shared dict across calls
|
||||
if not isinstance(kwargs, dict):
|
||||
kwargs = {}
|
||||
else:
|
||||
kwargs = dict(kwargs)
|
||||
log.debug("Creating buffer of type {0} with parent_tab of {2} arguments {1}".format(buffer_type, kwargs, parent_tab))
|
||||
if kwargs.get("parent") == None:
|
||||
kwargs["parent"] = self.view.nb
|
||||
if not hasattr(buffers, session_type):
|
||||
if not hasattr(buffers, session_type) and session_type != "blueski": # Allow blueski to be handled separately
|
||||
raise AttributeError("Session type %s does not exist yet." % (session_type))
|
||||
available_buffers = getattr(buffers, session_type)
|
||||
if not hasattr(available_buffers, buffer_type):
|
||||
raise AttributeError("Specified buffer type does not exist: %s" % (buffer_type,))
|
||||
buffer = getattr(available_buffers, buffer_type)(**kwargs)
|
||||
if start:
|
||||
if kwargs.get("function") == "user_timeline":
|
||||
|
||||
try:
|
||||
buffer_panel_class = None
|
||||
if session_type == "blueski":
|
||||
from controller.buffers.blueski import timeline as BlueskiTimelines
|
||||
from controller.buffers.blueski import user as BlueskiUsers
|
||||
from controller.buffers.blueski import chat as BlueskiChats
|
||||
|
||||
if "user_id" in kwargs and "session" not in kwargs:
|
||||
kwargs["session"] = sessions.sessions.get(kwargs["user_id"])
|
||||
|
||||
if "name" not in kwargs: kwargs["name"] = buffer_title
|
||||
|
||||
buffer_map = {
|
||||
"home_timeline": BlueskiTimelines.HomeTimeline,
|
||||
"following_timeline": BlueskiTimelines.FollowingTimeline,
|
||||
"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,
|
||||
"BlocksBuffer": BlueskiUsers.BlocksBuffer,
|
||||
"PostUserListBuffer": BlueskiUsers.PostUserListBuffer,
|
||||
"ConversationListBuffer": BlueskiChats.ConversationListBuffer,
|
||||
"ChatMessageBuffer": BlueskiChats.ChatBuffer,
|
||||
"chat_messages": BlueskiChats.ChatBuffer,
|
||||
"UserTimeline": BlueskiTimelines.UserTimeline,
|
||||
"user_timeline": BlueskiTimelines.UserTimeline,
|
||||
}
|
||||
|
||||
buffer_panel_class = buffer_map.get(buffer_type)
|
||||
if buffer_panel_class is None:
|
||||
# Fallback for others including user_timeline to HomeTimeline for now
|
||||
log.warning(f"Unsupported Blueski buffer type: {buffer_type}. Falling back to HomeTimeline.")
|
||||
buffer_panel_class = BlueskiTimelines.HomeTimeline
|
||||
else: # Existing logic for other session types
|
||||
available_buffers = getattr(buffers, session_type)
|
||||
if not hasattr(available_buffers, buffer_type):
|
||||
raise AttributeError("Specified buffer type does not exist: %s" % (buffer_type,))
|
||||
buffer_panel_class = getattr(available_buffers, buffer_type)
|
||||
|
||||
# Instantiate the panel
|
||||
# Ensure 'parent' kwarg is correctly set if not already
|
||||
if "parent" not in kwargs:
|
||||
kwargs["parent"] = self.view.nb # self.view.nb is the wx.Treebook
|
||||
|
||||
buffer = buffer_panel_class(**kwargs) # This is the wx.Panel instance
|
||||
buffer.controller = self
|
||||
|
||||
if start:
|
||||
try:
|
||||
buffer.start_stream(play_sound=False)
|
||||
if hasattr(buffer, "start_stream"):
|
||||
if kwargs.get("function") == "user_timeline":
|
||||
buffer.start_stream(mandatory=True, play_sound=False)
|
||||
else:
|
||||
buffer.start_stream(mandatory=True, play_sound=True)
|
||||
except ValueError:
|
||||
commonMessageDialogs.unauthorized()
|
||||
return
|
||||
self.buffers.append(buffer)
|
||||
if parent_tab == None:
|
||||
log.debug("Appending buffer {}...".format(buffer,))
|
||||
self.view.add_buffer(buffer.buffer, buffer_title)
|
||||
else:
|
||||
call_threaded(buffer.start_stream)
|
||||
self.buffers.append(buffer)
|
||||
if parent_tab == None:
|
||||
log.debug("Appending buffer {}...".format(buffer,))
|
||||
self.view.add_buffer(buffer.buffer, buffer_title)
|
||||
else:
|
||||
self.view.insert_buffer(buffer.buffer, buffer_title, parent_tab)
|
||||
log.debug("Inserting buffer {0} into control {1}".format(buffer, parent_tab))
|
||||
self.view.insert_buffer(buffer.buffer, buffer_title, parent_tab)
|
||||
log.debug("Inserting buffer {0} into control {1}".format(buffer, parent_tab))
|
||||
except Exception:
|
||||
log.exception("Error creating buffer '%s' for session_type '%s'", buffer_type, session_type)
|
||||
|
||||
def set_buffer_positions(self, session):
|
||||
"Sets positions for buffers if values exist in the database."
|
||||
@@ -511,30 +656,216 @@ class Controller(object):
|
||||
if hasattr(buffer, "post_status"):
|
||||
buffer.post_status()
|
||||
|
||||
def handle_compose_dialog_send(self, session, text, files, reply_to, cw_text, is_sensitive, kwargs, dialog_instance):
|
||||
"""Handles the actual sending of a post after ComposeDialog publishes data."""
|
||||
async def do_send_post():
|
||||
try:
|
||||
wx.CallAfter(dialog_instance.send_btn.Disable)
|
||||
wx.CallAfter(wx.BeginBusyCursor)
|
||||
|
||||
post_uri = await session.send_message(
|
||||
message=text,
|
||||
files=files,
|
||||
reply_to=reply_to,
|
||||
cw_text=cw_text,
|
||||
is_sensitive=is_sensitive,
|
||||
**kwargs
|
||||
)
|
||||
if post_uri:
|
||||
output.speak(_("Post sent successfully!"), True)
|
||||
wx.CallAfter(dialog_instance.EndModal, wx.ID_OK)
|
||||
# Optionally, add to relevant buffer or update UI
|
||||
# This might involve fetching the new post and adding to message_cache and posts_buffer
|
||||
# new_post_data = await session.util.get_post_by_uri(post_uri) # Assuming such a util method
|
||||
# if new_post_data:
|
||||
# await self.check_buffers(new_post_data) # check_buffers needs to handle PostView or dict
|
||||
else:
|
||||
# This case should ideally be handled by send_message raising an error
|
||||
output.speak(_("Failed to send post. The server did not confirm the post creation."), True)
|
||||
wx.CallAfter(dialog_instance.send_btn.Enable, True)
|
||||
|
||||
except NotificationError as e:
|
||||
logger.error(f"NotificationError sending post via dialog: {e}", exc_info=True)
|
||||
output.speak(_("Error sending post: {error}").format(error=str(e)), True)
|
||||
wx.CallAfter(wx.MessageBox, str(e), _("Post Error"), wx.OK | wx.ICON_ERROR, dialog_instance)
|
||||
if not dialog_instance.IsBeingDeleted(): wx.CallAfter(dialog_instance.send_btn.Enable, True)
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error sending post via dialog: {e}", exc_info=True)
|
||||
output.speak(_("An unexpected error occurred: {error}").format(error=str(e)), True)
|
||||
wx.CallAfter(wx.MessageBox, str(e), _("Error"), wx.OK | wx.ICON_ERROR, dialog_instance)
|
||||
if not dialog_instance.IsBeingDeleted(): wx.CallAfter(dialog_instance.send_btn.Enable, True)
|
||||
finally:
|
||||
if not dialog_instance.IsBeingDeleted(): wx.CallAfter(wx.EndBusyCursor)
|
||||
|
||||
asyncio.create_task(do_send_post())
|
||||
|
||||
|
||||
def post_reply(self, *args, **kwargs):
|
||||
buffer = self.get_current_buffer()
|
||||
if hasattr(buffer, "reply"):
|
||||
return buffer.reply()
|
||||
|
||||
|
||||
def send_dm(self, *args, **kwargs):
|
||||
buffer = self.get_current_buffer()
|
||||
if buffer is None:
|
||||
output.speak(_("No buffer selected."), True)
|
||||
return
|
||||
if hasattr(buffer, "send_message"):
|
||||
buffer.send_message()
|
||||
else:
|
||||
output.speak(_("Cannot send messages from this buffer."), True)
|
||||
|
||||
def post_retweet(self, *args, **kwargs):
|
||||
buffer = self.get_current_buffer()
|
||||
if hasattr(buffer, "share_item"):
|
||||
return buffer.share_item()
|
||||
session = getattr(buffer, "session", None)
|
||||
if not session:
|
||||
return
|
||||
if getattr(session, "type", "") == "blueski":
|
||||
item_uri = None
|
||||
if hasattr(buffer, "get_selected_item_id"):
|
||||
item_uri = buffer.get_selected_item_id()
|
||||
if not item_uri:
|
||||
output.speak(_("No item selected."), True)
|
||||
return
|
||||
|
||||
if self.showing == False:
|
||||
dlg = wx.TextEntryDialog(None, _("Write your quote (optional):"), _("Quote"))
|
||||
if dlg.ShowModal() == wx.ID_OK:
|
||||
text = dlg.GetValue().strip()
|
||||
dlg.Destroy()
|
||||
try:
|
||||
if text:
|
||||
uri = session.send_message(text, quote_uri=item_uri)
|
||||
if uri:
|
||||
output.speak(_("Quote posted."), True)
|
||||
else:
|
||||
output.speak(_("Failed to send quote."), True)
|
||||
else:
|
||||
# Confirm repost (share) depending on preference (boost_mode)
|
||||
ask = True
|
||||
try:
|
||||
ask = session.settings["general"].get("boost_mode", "ask") == "ask"
|
||||
except Exception:
|
||||
ask = True
|
||||
if ask:
|
||||
confirm = wx.MessageDialog(None, _("Would you like to share this post?"), _("Boost"), wx.YES_NO|wx.ICON_QUESTION)
|
||||
if confirm.ShowModal() != wx.ID_YES:
|
||||
confirm.Destroy()
|
||||
return
|
||||
confirm.Destroy()
|
||||
r_uri = session.repost(item_uri)
|
||||
if r_uri:
|
||||
output.speak(_("Post shared."), True)
|
||||
else:
|
||||
output.speak(_("Failed to share post."), True)
|
||||
except Exception:
|
||||
log.exception("Error sharing/quoting Bluesky post (invisible)")
|
||||
output.speak(_("An error occurred while sharing the post."), True)
|
||||
else:
|
||||
dlg.Destroy()
|
||||
return
|
||||
|
||||
from wxUI.dialogs.blueski.postDialogs import Post as ATPostDialog
|
||||
dlg = ATPostDialog(caption=_("Quote post"))
|
||||
if dlg.ShowModal() == wx.ID_OK:
|
||||
text, files, cw_text, langs = dlg.get_payload()
|
||||
dlg.Destroy()
|
||||
try:
|
||||
if text or files or cw_text:
|
||||
uri = session.send_message(text, files=files, cw_text=cw_text, is_sensitive=bool(cw_text), languages=langs, quote_uri=item_uri)
|
||||
if uri:
|
||||
output.speak(_("Quote posted."), True)
|
||||
try:
|
||||
if hasattr(buffer, "start_stream"):
|
||||
buffer.start_stream(mandatory=False, play_sound=False)
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
output.speak(_("Failed to send quote."), True)
|
||||
else:
|
||||
# Confirm repost without comment depending on preference
|
||||
ask = True
|
||||
try:
|
||||
ask = session.settings["general"].get("boost_mode", "ask") == "ask"
|
||||
except Exception:
|
||||
ask = True
|
||||
if ask:
|
||||
confirm = wx.MessageDialog(self.view, _("Would you like to share this post?"), _("Boost"), wx.YES_NO|wx.ICON_QUESTION)
|
||||
if confirm.ShowModal() != wx.ID_YES:
|
||||
confirm.Destroy()
|
||||
return
|
||||
confirm.Destroy()
|
||||
r_uri = session.repost(item_uri)
|
||||
if r_uri:
|
||||
output.speak(_("Post shared."), True)
|
||||
else:
|
||||
output.speak(_("Failed to share post."), True)
|
||||
except Exception:
|
||||
log.exception("Error sharing/quoting Bluesky post (dialog)")
|
||||
output.speak(_("An error occurred while sharing the post."), True)
|
||||
else:
|
||||
dlg.Destroy()
|
||||
return
|
||||
|
||||
def add_to_favourites(self, *args, **kwargs):
|
||||
buffer = self.get_current_buffer()
|
||||
if hasattr(buffer, "add_to_favorites"):
|
||||
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":
|
||||
# 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):
|
||||
buffer = self.get_current_buffer()
|
||||
if hasattr(buffer, "remove_from_favorites"):
|
||||
if hasattr(buffer, "remove_from_favorites"): # Generic buffer method
|
||||
return buffer.remove_from_favorites()
|
||||
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 unlike."), True)
|
||||
return
|
||||
|
||||
like_uri = None
|
||||
# Check viewer state from message_cache first, then panel's internal viewer_states
|
||||
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):
|
||||
like_uri = cached_post["viewer"].get("like")
|
||||
elif hasattr(cached_post, "viewer") and cached_post.viewer: # SDK model
|
||||
like_uri = cached_post.viewer.like
|
||||
|
||||
if not like_uri and hasattr(buffer, "get_item_viewer_state"): # Fallback to panel's state if any
|
||||
like_uri = buffer.get_item_viewer_state(item_uri, "like_uri")
|
||||
|
||||
if not like_uri:
|
||||
output.speak(_("Could not find the original like record for this post, or it's already unliked."), True)
|
||||
logger.warning(f"Attempted to unlike post {item_uri} but its like_uri was not found.")
|
||||
return
|
||||
|
||||
social_handler = self.get_handler(buffer.session.KIND)
|
||||
async def _unlike():
|
||||
result = await social_handler.unlike_item(buffer.session, like_uri)
|
||||
wx.CallAfter(output.speak, result["message"], True)
|
||||
if result.get("status") == "success":
|
||||
if hasattr(buffer, "store_item_viewer_state"):
|
||||
wx.CallAfter(buffer.store_item_viewer_state, item_uri, "like_uri", None)
|
||||
# Also update the item in message_cache
|
||||
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"] = None
|
||||
elif hasattr(cached_post, "viewer") and cached_post.viewer:
|
||||
cached_post.viewer.like = None
|
||||
asyncio.create_task(_unlike())
|
||||
|
||||
|
||||
def toggle_like(self, *args, **kwargs):
|
||||
buffer = self.get_current_buffer()
|
||||
@@ -621,6 +952,8 @@ class Controller(object):
|
||||
|
||||
def buffer_changed(self, *args, **kwargs):
|
||||
buffer = self.get_current_buffer()
|
||||
if buffer is None:
|
||||
return
|
||||
old_account = self.current_account
|
||||
new_account = buffer.account
|
||||
if new_account != old_account:
|
||||
@@ -641,18 +974,81 @@ class Controller(object):
|
||||
self.view.check_menuitem("autoread", autoread)
|
||||
|
||||
def update_menus(self, handler):
|
||||
# Initialize storage for hidden menu items if not present
|
||||
if not hasattr(self, "_hidden_menu_items"):
|
||||
self._hidden_menu_items = {}
|
||||
if not hasattr(self, "_original_menu_labels"):
|
||||
self._original_menu_labels = {}
|
||||
|
||||
if hasattr(handler, "menus"):
|
||||
for m in list(handler.menus.keys()):
|
||||
if hasattr(self.view, m):
|
||||
menu_item = getattr(self.view, m)
|
||||
if handler.menus[m] == None:
|
||||
# Store original label on first encounter
|
||||
if m not in self._original_menu_labels:
|
||||
self._original_menu_labels[m] = menu_item.GetItemLabel()
|
||||
|
||||
if handler.menus[m] == "HIDE":
|
||||
# Actually hide the menu item by removing it from parent menu
|
||||
if m not in self._hidden_menu_items:
|
||||
try:
|
||||
parent_menu = menu_item.GetMenu()
|
||||
if parent_menu:
|
||||
# Store menu item info for restoration
|
||||
position = self._find_menu_item_position(parent_menu, menu_item)
|
||||
item_id = menu_item.GetId()
|
||||
# Remove returns the removed item - store that reference
|
||||
removed_item = parent_menu.Remove(item_id)
|
||||
if removed_item:
|
||||
self._hidden_menu_items[m] = {
|
||||
"menu": parent_menu,
|
||||
"item": removed_item,
|
||||
"position": position
|
||||
}
|
||||
except Exception as e:
|
||||
log.error(f"Error hiding menu item {m}: {e}")
|
||||
elif handler.menus[m] == None:
|
||||
# Restore if hidden, then disable
|
||||
self._restore_menu_item(m)
|
||||
menu_item.Enable(False)
|
||||
else:
|
||||
# Restore if hidden, then enable with new label
|
||||
self._restore_menu_item(m)
|
||||
menu_item.Enable(True)
|
||||
menu_item.SetItemLabel(handler.menus[m])
|
||||
if hasattr(handler, "item_menu"):
|
||||
self.view.menubar.SetMenuLabel(1, handler.item_menu)
|
||||
|
||||
def _find_menu_item_position(self, menu, item):
|
||||
"""Find the position of a menu item within its parent menu."""
|
||||
for i, menu_item in enumerate(menu.GetMenuItems()):
|
||||
if menu_item.GetId() == item.GetId():
|
||||
return i
|
||||
return -1
|
||||
|
||||
def _restore_menu_item(self, name):
|
||||
"""Restore a previously hidden menu item."""
|
||||
if not hasattr(self, "_hidden_menu_items"):
|
||||
return
|
||||
if name not in self._hidden_menu_items:
|
||||
return
|
||||
info = self._hidden_menu_items[name]
|
||||
parent_menu = info["menu"]
|
||||
item = info["item"]
|
||||
position = info["position"]
|
||||
try:
|
||||
# Re-insert at original position
|
||||
if position >= 0 and position < parent_menu.GetMenuItemCount():
|
||||
parent_menu.Insert(position, item)
|
||||
else:
|
||||
parent_menu.Append(item)
|
||||
# Restore original label if available
|
||||
if hasattr(self, "_original_menu_labels") and name in self._original_menu_labels:
|
||||
item.SetItemLabel(self._original_menu_labels[name])
|
||||
except Exception as e:
|
||||
log.error(f"Error restoring menu item {name}: {e}")
|
||||
del self._hidden_menu_items[name]
|
||||
|
||||
def fix_wrong_buffer(self):
|
||||
buf = self.get_best_buffer()
|
||||
if buf == None:
|
||||
@@ -741,6 +1137,9 @@ class Controller(object):
|
||||
output.speak(msg, True)
|
||||
|
||||
def next_account(self, *args, **kwargs):
|
||||
if not self.accounts:
|
||||
output.speak(_("No accounts available."), True)
|
||||
return
|
||||
try:
|
||||
index = self.accounts.index(self.current_account)
|
||||
except ValueError:
|
||||
@@ -753,11 +1152,11 @@ class Controller(object):
|
||||
self.current_account = account
|
||||
buffer_object = self.get_first_buffer(account)
|
||||
if buffer_object == None:
|
||||
output.speak(_(u"{0}: This account is not logged into Twitter.").format(account), True)
|
||||
output.speak(_(u"{0}: This account is not logged in.").format(account), True)
|
||||
return
|
||||
buff = self.view.search(buffer_object.name, account)
|
||||
if buff == None:
|
||||
output.speak(_(u"{0}: This account is not logged into Twitter.").format(account), True)
|
||||
output.speak(_(u"{0}: This account is not logged in.").format(account), True)
|
||||
return
|
||||
self.view.change_buffer(buff)
|
||||
buffer = self.get_current_buffer()
|
||||
@@ -769,6 +1168,9 @@ class Controller(object):
|
||||
output.speak(msg, True)
|
||||
|
||||
def previous_account(self, *args, **kwargs):
|
||||
if not self.accounts:
|
||||
output.speak(_("No accounts available."), True)
|
||||
return
|
||||
try:
|
||||
index = self.accounts.index(self.current_account)
|
||||
except ValueError:
|
||||
@@ -781,11 +1183,11 @@ class Controller(object):
|
||||
self.current_account = account
|
||||
buffer_object = self.get_first_buffer(account)
|
||||
if buffer_object == None:
|
||||
output.speak(_(u"{0}: This account is not logged into Twitter.").format(account), True)
|
||||
output.speak(_(u"{0}: This account is not logged in.").format(account), True)
|
||||
return
|
||||
buff = self.view.search(buffer_object.name, account)
|
||||
if buff == None:
|
||||
output.speak(_(u"{0}: This account is not logged into twitter.").format(account), True)
|
||||
output.speak(_(u"{0}: This account is not logged in.").format(account), True)
|
||||
return
|
||||
self.view.change_buffer(buff)
|
||||
buffer = self.get_current_buffer()
|
||||
@@ -1031,14 +1433,125 @@ class Controller(object):
|
||||
log.exception("Error %s starting buffer %s on account %s, with args %r and kwargs %r." % (str(err), i.name, i.account, i.args, i.kwargs))
|
||||
|
||||
def update_buffer(self, *args, **kwargs):
|
||||
bf = self.get_current_buffer()
|
||||
if not hasattr(bf, "start_stream"):
|
||||
output.speak(_(u"Unable to update this buffer."))
|
||||
"""Handles the 'Update buffer' menu command to fetch newest items."""
|
||||
bf = self.get_current_buffer() # bf is the buffer panel instance
|
||||
if not bf or not hasattr(bf, "session") or not bf.session:
|
||||
output.speak(_(u"No active session for this buffer."), True)
|
||||
return
|
||||
output.speak(_(u"Updating buffer..."))
|
||||
n = bf.start_stream(mandatory=True, avoid_autoreading=True)
|
||||
if n != None:
|
||||
output.speak(_(u"{0} items retrieved").format(n,))
|
||||
|
||||
output.speak(_(u"Updating buffer..."), True)
|
||||
session = bf.session
|
||||
|
||||
output.speak(_(u"Updating buffer..."), True)
|
||||
session = bf.session
|
||||
|
||||
import threading
|
||||
is_blueski = (getattr(session, "KIND", None) == "blueski" or getattr(session, "type", None) == "blueski")
|
||||
|
||||
def do_update_sync():
|
||||
new_ids = []
|
||||
try:
|
||||
if is_blueski:
|
||||
if hasattr(bf, "start_stream"):
|
||||
count = bf.start_stream(mandatory=True)
|
||||
if count: new_ids = [str(x) for x in range(count)]
|
||||
else:
|
||||
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:
|
||||
wx.CallAfter(output.speak, _(u"Unable to update this buffer."), True)
|
||||
return
|
||||
|
||||
# Generic feedback
|
||||
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)
|
||||
wx.CallAfter(output.speak, _("An error occurred while updating the buffer."), True)
|
||||
|
||||
if is_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):
|
||||
"""Handles 'Load previous items' menu command."""
|
||||
bf = self.get_current_buffer() # bf is the buffer panel instance
|
||||
if not bf or not hasattr(bf, "session") or not bf.session:
|
||||
output.speak(_(u"No active session for this buffer."), True)
|
||||
return
|
||||
|
||||
session = bf.session
|
||||
# The buffer panel (bf) needs to store its own cursor for pagination of older items
|
||||
# e.g., bf.pagination_cursor or bf.older_items_cursor
|
||||
# This cursor should be set by the result of previous fetch_..._timeline(new_only=False) calls.
|
||||
|
||||
# For Blueski, session methods like fetch_home_timeline store their own cursor (e.g., session.home_timeline_cursor)
|
||||
# The panel (bf) itself should manage its own cursor for "load more"
|
||||
|
||||
current_cursor = None
|
||||
can_load_more_natively = False
|
||||
|
||||
if getattr(session, "KIND", None) == "blueski":
|
||||
if hasattr(bf, "load_more_posts"): # For BlueskiUserTimelinePanel & BlueskiHomeTimelinePanel
|
||||
can_load_more_natively = True
|
||||
if hasattr(bf, "load_more_posts"):
|
||||
can_load_more_natively = True
|
||||
elif hasattr(bf, "load_more_users"):
|
||||
can_load_more_natively = True
|
||||
elif bf.type == "notifications" and hasattr(bf, "load_more_notifications"): # Check for specific load_more
|
||||
can_load_more_natively = True
|
||||
elif bf.type == "notifications" and hasattr(bf, "refresh_notifications"): # Fallback for notifications to refresh
|
||||
# If load_more_notifications doesn't exist, 'Load More' will just refresh.
|
||||
can_load_more_natively = True # It will call refresh_notifications via the final 'else'
|
||||
else:
|
||||
if hasattr(bf, "get_more_items"):
|
||||
return bf.get_more_items()
|
||||
else:
|
||||
output.speak(_(u"This buffer does not support loading more items in this way."), True)
|
||||
return
|
||||
else: # For other non-Blueski session types
|
||||
if hasattr(bf, "get_more_items"):
|
||||
return bf.get_more_items()
|
||||
else:
|
||||
output.speak(_(u"This buffer does not support loading more items."), True)
|
||||
return
|
||||
|
||||
output.speak(_(u"Loading more items..."), True)
|
||||
|
||||
async def do_load_more():
|
||||
try:
|
||||
if session.KIND == "blueski":
|
||||
if hasattr(bf, "load_more_posts"):
|
||||
await bf.load_more_posts(limit=config.app["app-settings"].get("items_per_request", 20))
|
||||
elif hasattr(bf, "load_more_users"):
|
||||
await bf.load_more_users(limit=config.app["app-settings"].get("items_per_request", 30))
|
||||
elif bf.type == "notifications" and hasattr(bf, "refresh_notifications"):
|
||||
# This will re-fetch recent, not older. A true "load_more_notifications(cursor=...)" is needed for that.
|
||||
wx.CallAfter(output.speak, _("Refreshing notifications..."), True)
|
||||
await bf.refresh_notifications(limit=config.app["app-settings"].get("items_per_request", 20))
|
||||
# Feedback is handled by panel methods for consistency
|
||||
|
||||
except NotificationError as e:
|
||||
wx.CallAfter(output.speak, str(e), True)
|
||||
except Exception as e_general:
|
||||
logger.error(f"Error loading more items for buffer {bf.name}: {e_general}", exc_info=True)
|
||||
output.speak(_("An error occurred while loading more items."), True)
|
||||
|
||||
wx.CallAfter(asyncio.create_task, do_load_more())
|
||||
|
||||
|
||||
def buffer_title_changed(self, buffer):
|
||||
if buffer.name.endswith("-timeline"):
|
||||
@@ -1105,6 +1618,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:
|
||||
@@ -1131,21 +1671,57 @@ class Controller(object):
|
||||
def user_details(self, *args):
|
||||
"""Displays a user's profile."""
|
||||
log.debug("Showing user profile...")
|
||||
buffer = self.get_best_buffer()
|
||||
buffer = self.get_current_buffer() # Use current buffer to get context if item is selected
|
||||
if not buffer or not buffer.session:
|
||||
buffer = self.get_best_buffer() # Fallback if current buffer has no session
|
||||
|
||||
if not buffer or not buffer.session:
|
||||
output.speak(_("No active session to view user details."), True)
|
||||
return
|
||||
|
||||
handler = self.get_handler(type=buffer.session.type)
|
||||
if handler and hasattr(handler, 'user_details'):
|
||||
handler.user_details(buffer)
|
||||
# The handler's user_details method is responsible for extracting context
|
||||
# (e.g., selected user) from the buffer and displaying the profile.
|
||||
# For Blueski, handler.user_details calls the ShowUserProfileDialog.
|
||||
result = handler.user_details(buffer)
|
||||
if asyncio.iscoroutine(result):
|
||||
call_threaded(asyncio.run, result)
|
||||
else:
|
||||
output.speak(_("This session type does not support viewing user details in this way."), True)
|
||||
|
||||
|
||||
def openPostTimeline(self, *args, user=None): # "user" here is often the user object from selected item
|
||||
"""Opens selected user's posts timeline. Renamed to open_user_timeline in handlers for clarity."""
|
||||
current_buffer = self.get_current_buffer()
|
||||
if not current_buffer or not current_buffer.session:
|
||||
current_buffer = self.get_best_buffer()
|
||||
|
||||
if not current_buffer or not current_buffer.session:
|
||||
output.speak(_("No active session available."), True)
|
||||
return
|
||||
|
||||
session_to_use = current_buffer.session
|
||||
handler = self.get_handler(type=session_to_use.type)
|
||||
|
||||
# Prefer the new standardized 'open_user_timeline'
|
||||
if hasattr(handler, 'open_user_timeline'):
|
||||
user_payload = user # Use passed 'user' if available
|
||||
if user_payload is None and hasattr(current_buffer, 'get_selected_item_author_details'):
|
||||
author_details = current_buffer.get_selected_item_author_details()
|
||||
if author_details:
|
||||
user_payload = author_details
|
||||
|
||||
result = handler.open_user_timeline(main_controller=self, session=session_to_use, user_payload=user_payload)
|
||||
if asyncio.iscoroutine(result):
|
||||
call_threaded(asyncio.run, result)
|
||||
|
||||
elif hasattr(handler, 'openPostTimeline'): # Fallback for older handler structure
|
||||
# This path might not correctly pass main_controller if the old handler expects it differently
|
||||
handler.openPostTimeline(self, current_buffer, user)
|
||||
else:
|
||||
output.speak(_("This session type does not support opening user timelines directly."), True)
|
||||
|
||||
def openPostTimeline(self, *args, user=None):
|
||||
"""Opens selected user's posts timeline
|
||||
Parameters:
|
||||
args: Other argument. Useful when binding to widgets.
|
||||
user: if specified, open this user timeline. It is currently mandatory, but could be optional when user selection is implemented in handler
|
||||
"""
|
||||
buffer = self.get_best_buffer()
|
||||
handler = self.get_handler(type=buffer.session.type)
|
||||
if handler and hasattr(handler, 'openPostTimeline'):
|
||||
handler.openPostTimeline(self, buffer, user)
|
||||
|
||||
def openFollowersTimeline(self, *args, user=None):
|
||||
"""Opens selected user's followers timeline
|
||||
@@ -1153,10 +1729,30 @@ class Controller(object):
|
||||
args: Other argument. Useful when binding to widgets.
|
||||
user: if specified, open this user timeline. It is currently mandatory, but could be optional when user selection is implemented in handler
|
||||
"""
|
||||
buffer = self.get_best_buffer()
|
||||
handler = self.get_handler(type=buffer.session.type)
|
||||
if handler and hasattr(handler, 'openFollowersTimeline'):
|
||||
handler.openFollowersTimeline(self, buffer, user)
|
||||
current_buffer = self.get_current_buffer()
|
||||
if not current_buffer or not current_buffer.session:
|
||||
current_buffer = self.get_best_buffer()
|
||||
|
||||
if not current_buffer or not current_buffer.session:
|
||||
output.speak(_("No active session available."), True)
|
||||
return
|
||||
|
||||
session_to_use = current_buffer.session
|
||||
handler = self.get_handler(type=session_to_use.type)
|
||||
|
||||
if user is None and hasattr(current_buffer, 'get_selected_item_author_details'):
|
||||
author_details = current_buffer.get_selected_item_author_details()
|
||||
if author_details: user = author_details
|
||||
|
||||
if handler and hasattr(handler, 'open_followers_timeline'):
|
||||
result = handler.open_followers_timeline(main_controller=self, session=session_to_use, user_payload=user)
|
||||
if asyncio.iscoroutine(result):
|
||||
call_threaded(asyncio.run, result)
|
||||
elif handler and hasattr(handler, 'openFollowersTimeline'): # Fallback
|
||||
handler.openFollowersTimeline(self, current_buffer, user)
|
||||
else:
|
||||
output.speak(_("This session type does not support opening followers list."), True)
|
||||
|
||||
|
||||
def openFollowingTimeline(self, *args, user=None):
|
||||
"""Opens selected user's following timeline
|
||||
@@ -1164,12 +1760,32 @@ class Controller(object):
|
||||
args: Other argument. Useful when binding to widgets.
|
||||
user: if specified, open this user timeline. It is currently mandatory, but could be optional when user selection is implemented in handler
|
||||
"""
|
||||
buffer = self.get_best_buffer()
|
||||
handler = self.get_handler(type=buffer.session.type)
|
||||
if handler and hasattr(handler, 'openFollowingTimeline'):
|
||||
handler.openFollowingTimeline(self, buffer, user)
|
||||
current_buffer = self.get_current_buffer()
|
||||
if not current_buffer or not current_buffer.session:
|
||||
current_buffer = self.get_best_buffer()
|
||||
|
||||
def community_timeline(self, *args, user=None):
|
||||
if not current_buffer or not current_buffer.session:
|
||||
output.speak(_("No active session available."), True)
|
||||
return
|
||||
|
||||
session_to_use = current_buffer.session
|
||||
handler = self.get_handler(type=session_to_use.type)
|
||||
|
||||
if user is None and hasattr(current_buffer, 'get_selected_item_author_details'):
|
||||
author_details = current_buffer.get_selected_item_author_details()
|
||||
if author_details: user = author_details
|
||||
|
||||
if handler and hasattr(handler, 'open_following_timeline'):
|
||||
result = handler.open_following_timeline(main_controller=self, session=session_to_use, user_payload=user)
|
||||
if asyncio.iscoroutine(result):
|
||||
call_threaded(asyncio.run, result)
|
||||
elif handler and hasattr(handler, 'openFollowingTimeline'): # Fallback
|
||||
handler.openFollowingTimeline(self, current_buffer, user)
|
||||
else:
|
||||
output.speak(_("This session type does not support opening following list."), True)
|
||||
|
||||
|
||||
def community_timeline(self, *args, user=None): # user param seems unused here based on mastodon impl
|
||||
buffer = self.get_best_buffer()
|
||||
handler = self.get_handler(type=buffer.session.type)
|
||||
if handler and hasattr(handler, 'community_timeline'):
|
||||
@@ -1185,4 +1801,4 @@ class Controller(object):
|
||||
buffer = self.get_best_buffer()
|
||||
handler = self.get_handler(type=buffer.session.type)
|
||||
if handler and hasattr(handler, 'manage_filters'):
|
||||
handler.manage_filters(self, buffer)
|
||||
handler.manage_filters(self, buffer)
|
||||
|
||||
@@ -456,4 +456,4 @@ class text(messages.basicMessage):
|
||||
self.message = postDialogs.viewText(title=title, text=text, *args, **kwargs)
|
||||
self.message.text.SetInsertionPoint(len(self.message.text.GetValue()))
|
||||
widgetUtils.connect_event(self.message.spellcheck, widgetUtils.BUTTON_PRESSED, self.spellcheck)
|
||||
widgetUtils.connect_event(self.message.translateButton, widgetUtils.BUTTON_PRESSED, self.translate)
|
||||
widgetUtils.connect_event(self.message.translateButton, widgetUtils.BUTTON_PRESSED, self.translate)
|
||||
|
||||
@@ -39,4 +39,4 @@ class EditTemplate(object):
|
||||
else:
|
||||
return dialog.template.GetValue()
|
||||
else:
|
||||
return ""
|
||||
return ""
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
from . import mastodon
|
||||
from . import mastodon
|
||||
from . import blueski
|
||||
@@ -0,0 +1,52 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
actions = {
|
||||
"up": _(u"Go up in the current buffer"),
|
||||
"down": _(u"Go down in the current buffer"),
|
||||
"left": _(u"Go to the previous buffer"),
|
||||
"right": _(u"Go to the next buffer"),
|
||||
"next_account": _(u"Focus the next session"),
|
||||
"previous_account": _(u"Focus the previous session"),
|
||||
"show_hide": _(u"Show or hide the GUI"),
|
||||
"post_tweet": _("Make a new post"),
|
||||
"post_reply": _(u"Reply"),
|
||||
"post_retweet": _(u"Repost"),
|
||||
"send_dm": _(u"Send direct message"),
|
||||
"add_to_favourites": _("Add post to likes"),
|
||||
"remove_from_favourites": _(u"Remove post from likes"),
|
||||
"toggle_like": _("Add/remove post from likes"),
|
||||
"follow": _(u"Open the user actions dialogue"),
|
||||
"user_details": _(u"See user details"),
|
||||
"view_item": _(u"Show post"),
|
||||
"exit": _(u"Quit"),
|
||||
"open_timeline": _(u"Open user timeline"),
|
||||
"remove_buffer": _(u"Destroy buffer"),
|
||||
"url": _(u"Open URL"),
|
||||
"open_in_browser": _(u"View in browser"),
|
||||
"volume_up": _(u"Increase volume by 5%"),
|
||||
"volume_down": _(u"Decrease volume by 5%"),
|
||||
"go_home": _(u"Jump to the first element of a 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_down": _(u"Jump 20 elements down in the current buffer"),
|
||||
"delete": _("Delete post"),
|
||||
"clear_buffer": _(u"Empty the current buffer"),
|
||||
"repeat_item": _(u"Repeat last item"),
|
||||
"copy_to_clipboard": _(u"Copy to clipboard"),
|
||||
"toggle_buffer_mute": _(u"Mute/unmute the active buffer"),
|
||||
"toggle_session_mute": _(u"Mute/unmute the current session"),
|
||||
"toggle_autoread": _(u"Toggle the automatic reading of incoming posts in the active buffer"),
|
||||
"search": _(u"Search"),
|
||||
"find": _(u"Find a string in the currently focused buffer"),
|
||||
"edit_keystrokes": _(u"Show the keystroke editor"),
|
||||
"get_more_items": _(u"Load previous items"),
|
||||
"open_conversation": _(u"View conversation"),
|
||||
"check_for_updates": _(u"Check and download updates"),
|
||||
"configuration": _(u"Opens the global settings dialogue"),
|
||||
"accountConfiguration": _(u"Opens the account settings dialogue"),
|
||||
"audio": _(u"Try to play a media file"),
|
||||
"update_buffer": _(u"Updates the buffer and retrieves possible lost items there."),
|
||||
"ocr_image": _(u"Extracts the text from a picture and displays the result in a dialog."),
|
||||
"seekLeft": _(u"Seek media backward"),
|
||||
"seekRight": _(u"Seek media forward"),
|
||||
"manage_accounts": _(u"Manage accounts"),
|
||||
}
|
||||
@@ -63,13 +63,17 @@ class list(object):
|
||||
|
||||
def get_selected(self):
|
||||
if self.system == "Windows":
|
||||
return self.list.GetFocusedItem()
|
||||
item = self.list.GetFocusedItem()
|
||||
if item == -1:
|
||||
item = self.list.GetFirstSelected()
|
||||
return item
|
||||
else:
|
||||
return self.list.GetSelection()
|
||||
|
||||
def select_item(self, pos):
|
||||
if self.system == "Windows":
|
||||
self.list.Focus(pos)
|
||||
self.list.Select(pos)
|
||||
else:
|
||||
self.list.SetSelection(pos)
|
||||
|
||||
|
||||
@@ -11,10 +11,13 @@ import paths
|
||||
import config_utils
|
||||
import config
|
||||
import application
|
||||
import asyncio # For async event handling
|
||||
import wx
|
||||
from pubsub import pub
|
||||
from controller import settings
|
||||
from sessions.mastodon import session as MastodonSession
|
||||
from sessions.gotosocial import session as GotosocialSession
|
||||
from sessions.blueski import session as BlueskiSession # Import Blueski session
|
||||
from . import manager
|
||||
from . import wxUI as view
|
||||
|
||||
@@ -35,6 +38,7 @@ class sessionManagerController(object):
|
||||
# Initialize the manager, responsible for storing session objects.
|
||||
manager.setup()
|
||||
self.view = view.sessionManagerWindow()
|
||||
# Handle new account synchronously on the UI thread
|
||||
pub.subscribe(self.manage_new_account, "sessionmanager.new_account")
|
||||
pub.subscribe(self.remove_account, "sessionmanager.remove_account")
|
||||
if self.started == False:
|
||||
@@ -67,12 +71,44 @@ class sessionManagerController(object):
|
||||
continue
|
||||
if config_test.get("mastodon") != None:
|
||||
name = _("{account_name}@{instance} (Mastodon)").format(account_name=config_test["mastodon"]["user_name"], instance=config_test["mastodon"]["instance"].replace("https://", ""))
|
||||
if config_test["mastodon"]["instance"] != "" and config_test["mastodon"]["access_token"] != "":
|
||||
if config_test["mastodon"]["instance"] != "" and config_test["mastodon"]["access_token"] != "": # Basic validation
|
||||
sessionsList.append(name)
|
||||
self.sessions.append(dict(type=config_test["mastodon"].get("type", "mastodon"), id=i))
|
||||
else:
|
||||
elif config_test.get("blueski") != None: # Check for Blueski config
|
||||
handle = config_test["blueski"].get("handle")
|
||||
did = config_test["blueski"].get("did") # DID confirms it was authorized
|
||||
if handle and did:
|
||||
name = _("{handle} (Bluesky)").format(handle=handle)
|
||||
sessionsList.append(name)
|
||||
self.sessions.append(dict(type="blueski", id=i))
|
||||
else: # Incomplete config, might be an old attempt or error
|
||||
log.warning(f"Incomplete Blueski session config found for {i}, skipping.")
|
||||
# Optionally delete malformed config here too
|
||||
try:
|
||||
log.debug("Deleting incomplete Blueski session %s" % (i,))
|
||||
shutil.rmtree(os.path.join(paths.config_path(), i))
|
||||
except Exception as e:
|
||||
log.exception(f"Error deleting incomplete Blueski session {i}: {e}")
|
||||
continue
|
||||
elif config_test.get("atprotosocial") != None: # Legacy config namespace
|
||||
handle = config_test["atprotosocial"].get("handle")
|
||||
did = config_test["atprotosocial"].get("did")
|
||||
if handle and did:
|
||||
name = _("{handle} (Bluesky)").format(handle=handle)
|
||||
sessionsList.append(name)
|
||||
self.sessions.append(dict(type="blueski", id=i))
|
||||
else: # Incomplete config, might be an old attempt or error
|
||||
log.warning(f"Incomplete Blueski session config found for {i}, skipping.")
|
||||
# Optionally delete malformed config here too
|
||||
try:
|
||||
log.debug("Deleting incomplete Blueski session %s" % (i,))
|
||||
shutil.rmtree(os.path.join(paths.config_path(), i))
|
||||
except Exception as e:
|
||||
log.exception(f"Error deleting incomplete Blueski session {i}: {e}")
|
||||
continue
|
||||
else: # Unknown or other session type not explicitly handled here for display
|
||||
try:
|
||||
log.debug("Deleting session %s" % (i,))
|
||||
log.debug("Deleting session %s with unknown type" % (i,))
|
||||
shutil.rmtree(os.path.join(paths.config_path(), i))
|
||||
except:
|
||||
output.speak("An exception was raised while attempting to clean malformed session data. See the error log for details. If this message persists, contact the developers.",True)
|
||||
@@ -97,36 +133,112 @@ class sessionManagerController(object):
|
||||
s = MastodonSession.Session(i.get("id"))
|
||||
elif i.get("type") == "gotosocial":
|
||||
s = GotosocialSession.Session(i.get("id"))
|
||||
s.get_configuration()
|
||||
if i.get("id") not in config.app["sessions"]["ignored_sessions"]:
|
||||
try:
|
||||
elif i.get("type") == "blueski": # Handle Blueski session type
|
||||
s = BlueskiSession.Session(i.get("id"))
|
||||
else:
|
||||
log.warning(f"Unknown session type '{i.get('type')}' for ID {i.get('id')}. Skipping.")
|
||||
continue
|
||||
|
||||
s.get_configuration() # Load per-session configuration
|
||||
# For Blueski, this loads from its specific config file.
|
||||
|
||||
# Login is now primarily handled by session.start() via mainController,
|
||||
# which calls _ensure_dependencies_ready().
|
||||
# Explicit s.login() here might be redundant or premature if full app context isn't ready.
|
||||
# We'll rely on the mainController to call session.start() which handles login.
|
||||
# if i.get("id") not in config.app["sessions"]["ignored_sessions"]:
|
||||
# try:
|
||||
# # For Blueski, login is async and handled by session.start()
|
||||
# # if not s.is_ready(): # Only attempt login if not already ready
|
||||
# # log.info(f"Session {s.uid} ({s.kind}) not ready, login will be attempted by start().")
|
||||
# pass
|
||||
# except Exception as e:
|
||||
# log.exception(f"Exception during pre-emptive login check for session {s.uid} ({s.kind}).")
|
||||
# continue
|
||||
# Try to auto-login for Blueski so the app starts with buffers ready
|
||||
try:
|
||||
if i.get("type") == "blueski":
|
||||
s.login()
|
||||
except Exception as e:
|
||||
log.exception("Exception during login on a TWBlue session.")
|
||||
continue
|
||||
sessions.sessions[i.get("id")] = s
|
||||
self.new_sessions[i.get("id")] = s
|
||||
except Exception:
|
||||
log.exception("Auto-login failed for Blueski session %s", i.get("id"))
|
||||
|
||||
sessions.sessions[i.get("id")] = s # Add to global session store
|
||||
self.new_sessions[i.get("id")] = s # Track as a new session for this manager instance
|
||||
# self.view.destroy()
|
||||
|
||||
def show_auth_error(self):
|
||||
error = view.auth_error()
|
||||
error = view.auth_error() # This seems to be a generic auth error display
|
||||
|
||||
def manage_new_account(self, type):
|
||||
# Generic settings for all account types.
|
||||
location = (str(time.time())[-6:])
|
||||
location = (str(time.time())[-6:]) # Unique ID for the session config directory
|
||||
log.debug("Creating %s session in the %s path" % (type, location))
|
||||
|
||||
s: sessions.base.baseSession | None = None # Type hint for session object
|
||||
|
||||
if type == "mastodon":
|
||||
s = MastodonSession.Session(location)
|
||||
result = s.authorise()
|
||||
if result == True:
|
||||
self.sessions.append(dict(id=location, type=s.settings["mastodon"].get("type")))
|
||||
self.view.add_new_session_to_list()
|
||||
elif type == "blueski":
|
||||
s = BlueskiSession.Session(location)
|
||||
# Add other session types here if needed (e.g., gotosocial)
|
||||
# elif type == "gotosocial":
|
||||
# s = GotosocialSession.Session(location)
|
||||
|
||||
if not s:
|
||||
log.error(f"Unsupported session type for creation: {type}")
|
||||
self.view.show_unauthorised_error() # Or a more generic "cannot create" error
|
||||
return
|
||||
|
||||
try:
|
||||
result = s.authorise()
|
||||
if result == True:
|
||||
# Session config (handle, did for atproto) should be saved by authorise/login.
|
||||
# Here we just update the session manager's internal list and UI.
|
||||
session_type_for_dict = type # Store the actual type string
|
||||
if hasattr(s, 'settings') and s.settings and s.settings.get(type) and s.settings[type].get("type"):
|
||||
# Mastodon might have a more specific type in its settings (e.g. gotosocial)
|
||||
session_type_for_dict = s.settings[type].get("type")
|
||||
|
||||
self.sessions.append(dict(id=location, type=session_type_for_dict))
|
||||
self.view.add_new_session_to_list() # This should update the UI list
|
||||
# The session object 's' itself isn't stored in self.new_sessions until do_ok if app is restarting
|
||||
# But for immediate use if not restarting, it might need to be added to sessions.sessions
|
||||
sessions.sessions[location] = s # Make it globally available immediately
|
||||
self.new_sessions[location] = s
|
||||
# Sync with global config
|
||||
if location not in config.app["sessions"]["sessions"]:
|
||||
config.app["sessions"]["sessions"].append(location)
|
||||
config.app.write()
|
||||
|
||||
|
||||
else: # Authorise returned False or None
|
||||
self.view.show_unauthorised_error()
|
||||
# Clean up the directory if authorization failed and nothing was saved
|
||||
if os.path.exists(os.path.join(paths.config_path(), location)):
|
||||
try:
|
||||
shutil.rmtree(os.path.join(paths.config_path(), location))
|
||||
log.info(f"Cleaned up directory for failed auth: {location}")
|
||||
except Exception as e_rm:
|
||||
log.error(f"Error cleaning up directory {location} after failed auth: {e_rm}")
|
||||
except Exception as e:
|
||||
log.error(f"Error during new account authorization for type {type}: {e}", exc_info=True)
|
||||
self.view.show_unauthorised_error() # Show generic error
|
||||
# Clean up
|
||||
if os.path.exists(os.path.join(paths.config_path(), location)):
|
||||
try:
|
||||
shutil.rmtree(os.path.join(paths.config_path(), location))
|
||||
except Exception as e_rm:
|
||||
log.error(f"Error cleaning up directory {location} after exception: {e_rm}")
|
||||
|
||||
|
||||
def remove_account(self, index):
|
||||
selected_account = self.sessions[index]
|
||||
self.view.remove_session(index)
|
||||
self.removed_sessions.append(selected_account.get("id"))
|
||||
self.sessions.remove(selected_account)
|
||||
if selected_account.get("id") in config.app["sessions"]["sessions"]:
|
||||
config.app["sessions"]["sessions"].remove(selected_account.get("id"))
|
||||
config.app.write()
|
||||
shutil.rmtree(path=os.path.join(paths.config_path(), selected_account.get("id")), ignore_errors=True)
|
||||
|
||||
def configuration(self):
|
||||
|
||||
@@ -53,6 +53,10 @@ class sessionManagerWindow(wx.Dialog):
|
||||
menu = wx.Menu()
|
||||
mastodon = menu.Append(wx.ID_ANY, _("Mastodon"))
|
||||
menu.Bind(wx.EVT_MENU, self.on_new_mastodon_account, mastodon)
|
||||
|
||||
blueski = menu.Append(wx.ID_ANY, _("Bluesky"))
|
||||
menu.Bind(wx.EVT_MENU, self.on_new_blueski_account, blueski)
|
||||
|
||||
self.PopupMenu(menu, self.new.GetPosition())
|
||||
|
||||
def on_new_mastodon_account(self, *args, **kwargs):
|
||||
@@ -62,6 +66,13 @@ class sessionManagerWindow(wx.Dialog):
|
||||
if response == wx.ID_YES:
|
||||
pub.sendMessage("sessionmanager.new_account", type="mastodon")
|
||||
|
||||
def on_new_blueski_account(self, *args, **kwargs):
|
||||
dlg = wx.MessageDialog(self, _("You will be prompted for your Bluesky data (user handle and App Password) to authorize TWBlue. Would you like to authorize your account now?"), _(u"Bluesky Authorization"), wx.YES_NO)
|
||||
response = dlg.ShowModal()
|
||||
dlg.Destroy()
|
||||
if response == wx.ID_YES:
|
||||
pub.sendMessage("sessionmanager.new_account", type="blueski")
|
||||
|
||||
def add_new_session_to_list(self):
|
||||
total = self.list.get_count()
|
||||
name = _(u"Authorized account %d") % (total+1)
|
||||
|
||||
@@ -59,7 +59,9 @@ class baseSession(object):
|
||||
if not os.path.exists(path):
|
||||
log.debug("Creating %s path" % (os.path.join(paths.config_path(), path),))
|
||||
os.mkdir(path)
|
||||
config.app["sessions"]["sessions"].append(id)
|
||||
if self.session_id not in config.app["sessions"]["sessions"]:
|
||||
config.app["sessions"]["sessions"].append(self.session_id)
|
||||
config.app.write()
|
||||
|
||||
def get_configuration(self):
|
||||
""" Get settings for a session."""
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
from .session import Session
|
||||
|
||||
__all__ = ["Session"]
|
||||
@@ -0,0 +1,478 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Compose functions for Bluesky content display in TWBlue.
|
||||
|
||||
These functions format API data into user-readable strings for display in
|
||||
list controls. They follow the TWBlue compose function pattern:
|
||||
compose_function(item, db, relative_times, show_screen_names, session)
|
||||
Returns a list of strings for display columns.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import arrow
|
||||
import languageHandler
|
||||
from sessions.blueski import utils
|
||||
|
||||
log = logging.getLogger("sessions.blueski.compose")
|
||||
|
||||
|
||||
def compose_post(post, db, settings, relative_times, show_screen_names=False, safe=True):
|
||||
"""
|
||||
Compose a Bluesky post into a list of strings for display.
|
||||
Format matches Mastodon: [user+", ", text, date+", ", source]
|
||||
"""
|
||||
def g(obj, key, default=None):
|
||||
"""Helper to get attribute from dict or object."""
|
||||
if isinstance(obj, dict):
|
||||
return obj.get(key, default)
|
||||
return getattr(obj, key, default)
|
||||
|
||||
# Resolve Post View or Feed View structure
|
||||
actual_post = g(post, "post", post)
|
||||
record = g(actual_post, "record", {})
|
||||
author = g(actual_post, "author", {})
|
||||
|
||||
# Original author info
|
||||
original_handle = g(author, "handle", "")
|
||||
original_display_name = g(author, "displayName") or g(author, "display_name") or original_handle or "Unknown"
|
||||
|
||||
# Check if this is a repost
|
||||
reason = g(post, "reason", None)
|
||||
is_repost = False
|
||||
reposter_handle = ""
|
||||
reposter_display_name = ""
|
||||
|
||||
if reason:
|
||||
rtype = g(reason, "$type") or g(reason, "py_type")
|
||||
if rtype and "reasonRepost" in rtype:
|
||||
is_repost = True
|
||||
by = g(reason, "by", {})
|
||||
reposter_handle = g(by, "handle", "")
|
||||
reposter_display_name = g(by, "displayName") or g(by, "display_name") or reposter_handle
|
||||
|
||||
# User column: show reposter if repost, otherwise original author (like Mastodon)
|
||||
if is_repost and reposter_handle:
|
||||
if show_screen_names:
|
||||
user_str = f"@{reposter_handle}"
|
||||
else:
|
||||
if reposter_display_name and reposter_display_name != reposter_handle:
|
||||
user_str = f"{reposter_display_name} (@{reposter_handle})"
|
||||
else:
|
||||
user_str = f"@{reposter_handle}"
|
||||
else:
|
||||
if show_screen_names:
|
||||
user_str = f"@{original_handle}"
|
||||
else:
|
||||
if original_display_name and original_display_name != original_handle:
|
||||
user_str = f"{original_display_name} (@{original_handle})"
|
||||
else:
|
||||
user_str = f"@{original_handle}"
|
||||
|
||||
# Text
|
||||
original_text = g(record, "text", "")
|
||||
|
||||
# Build text - if repost, format like Mastodon: "Reposted from @original: text"
|
||||
if is_repost:
|
||||
text = _("Reposted from @{}: {}").format(original_handle, original_text)
|
||||
else:
|
||||
text = original_text
|
||||
|
||||
reply_to_handle = utils.extract_reply_to_handle(post)
|
||||
if reply_to_handle:
|
||||
if text:
|
||||
text = _("Replying to @{}: {}").format(reply_to_handle, text)
|
||||
else:
|
||||
text = _("Replying to @{}").format(reply_to_handle)
|
||||
|
||||
# Check facets for links not visible in text and append them
|
||||
facets = g(record, "facets", []) or []
|
||||
hidden_urls = []
|
||||
for facet in facets:
|
||||
features = g(facet, "features", []) or []
|
||||
for feature in features:
|
||||
ftype = g(feature, "$type") or g(feature, "py_type") or ""
|
||||
if "link" in ftype.lower():
|
||||
uri = g(feature, "uri", "")
|
||||
if uri and uri not in text and uri not in hidden_urls:
|
||||
# Check if a truncated version is in text (e.g., "example.com/path...")
|
||||
# by checking if the domain is present
|
||||
domain_match = False
|
||||
try:
|
||||
from urllib.parse import urlparse
|
||||
parsed = urlparse(uri)
|
||||
domain = parsed.netloc.replace("www.", "")
|
||||
if domain and domain in text:
|
||||
domain_match = True
|
||||
except:
|
||||
pass
|
||||
if not domain_match:
|
||||
hidden_urls.append(uri)
|
||||
|
||||
if hidden_urls:
|
||||
text += " " + " ".join(f"[{url}]" for url in hidden_urls)
|
||||
|
||||
# Labels / Content Warning
|
||||
labels = g(actual_post, "labels", [])
|
||||
cw_text = ""
|
||||
for label in labels:
|
||||
val = g(label, "val", "")
|
||||
if val in ["!warn", "porn", "sexual", "nudity", "gore", "graphic-media", "corpse", "self-harm", "hate", "spam", "impersonation"]:
|
||||
if not cw_text:
|
||||
cw_text = _("Sensitive Content")
|
||||
elif val.startswith("warn:"):
|
||||
cw_text = val.split("warn:", 1)[-1].strip()
|
||||
|
||||
if cw_text:
|
||||
text = f"CW: {cw_text}\n\n{text}"
|
||||
|
||||
# Embeds (Images, Links)
|
||||
embed = g(actual_post, "embed", None)
|
||||
if embed:
|
||||
etype = g(embed, "$type") or g(embed, "py_type")
|
||||
|
||||
# Images
|
||||
if etype and ("images" in etype):
|
||||
images = g(embed, "images", [])
|
||||
if images:
|
||||
text += f" [{len(images)} {_('images')}]"
|
||||
|
||||
if etype and ("recordWithMedia" in etype):
|
||||
media = g(embed, "media", {})
|
||||
mtype = g(media, "$type") or g(media, "py_type")
|
||||
if mtype and "images" in mtype:
|
||||
images = g(media, "images", [])
|
||||
if images:
|
||||
text += f" [{len(images)} {_('images')}]"
|
||||
elif mtype and "external" in mtype:
|
||||
ext = g(media, "external", {})
|
||||
title = g(ext, "title", "")
|
||||
if title:
|
||||
text += f" [{_('Link')}: {title}]"
|
||||
elif etype and ("external" in etype):
|
||||
ext = g(embed, "external", {})
|
||||
title = g(ext, "title", "")
|
||||
if title:
|
||||
text += f" [{_('Link')}: {title}]"
|
||||
|
||||
quote_info = utils.extract_quoted_post_info(post)
|
||||
if quote_info:
|
||||
if quote_info["kind"] == "not_found":
|
||||
text += f" [{_('Quoted post not found')}]"
|
||||
elif quote_info["kind"] == "blocked":
|
||||
text += f" [{_('Quoted post blocked')}]"
|
||||
elif quote_info["kind"] == "feed":
|
||||
text += f" [{_('Quoting Feed')}: {quote_info.get('feed_name', 'Feed')}]"
|
||||
else:
|
||||
q_handle = quote_info.get("handle", "unknown")
|
||||
q_text = quote_info.get("text", "")
|
||||
if q_text:
|
||||
text += " " + _("Quoting @{}: {}").format(q_handle, q_text)
|
||||
else:
|
||||
text += " " + _("Quoting @{}").format(q_handle)
|
||||
|
||||
# Add full URLs from quoted content when they are not visible in text.
|
||||
for uri in quote_info.get("urls", []):
|
||||
if uri and uri not in text:
|
||||
text += f" [{uri}]"
|
||||
|
||||
# Date
|
||||
indexed_at = g(actual_post, "indexed_at", "") or g(actual_post, "indexedAt", "")
|
||||
ts_str = ""
|
||||
if indexed_at:
|
||||
try:
|
||||
ts = arrow.get(indexed_at)
|
||||
if relative_times:
|
||||
ts_str = ts.humanize(locale=languageHandler.curLang[:2])
|
||||
else:
|
||||
ts_str = ts.format(_("dddd, MMMM D, YYYY H:m"), locale=languageHandler.curLang[:2])
|
||||
except Exception:
|
||||
ts_str = str(indexed_at)[:16].replace("T", " ")
|
||||
|
||||
# Source / Client
|
||||
source = "Bluesky"
|
||||
|
||||
# Format like Mastodon: add ", " after user and date
|
||||
return [user_str + ", ", text, ts_str + ", ", source]
|
||||
|
||||
|
||||
def compose_notification(notification, db, settings, relative_times, show_screen_names=False, safe=True):
|
||||
"""
|
||||
Compose a Bluesky notification into a list of strings for display.
|
||||
Format matches Mastodon: [user, text, date]
|
||||
"""
|
||||
def g(obj, key, default=None):
|
||||
if isinstance(obj, dict):
|
||||
return obj.get(key, default)
|
||||
return getattr(obj, key, default)
|
||||
|
||||
# Author of the notification (who performed the action)
|
||||
author = g(notification, "author", {})
|
||||
handle = g(author, "handle", "unknown")
|
||||
display_name = g(author, "displayName") or g(author, "display_name") or handle
|
||||
|
||||
if show_screen_names:
|
||||
user_str = f"@{handle}"
|
||||
else:
|
||||
if display_name and display_name != handle:
|
||||
user_str = f"{display_name} (@{handle})"
|
||||
else:
|
||||
user_str = f"@{handle}"
|
||||
|
||||
# Notification reason/type
|
||||
reason = g(notification, "reason", "unknown")
|
||||
|
||||
# Get post text - try multiple locations depending on notification type
|
||||
record = g(notification, "record", {})
|
||||
post_text = ""
|
||||
|
||||
# For mentions, replies, quotes: text is in the record itself
|
||||
post_text = g(record, "text", "")
|
||||
|
||||
# For likes and reposts: try to get the subject post text
|
||||
if not post_text and reason in ("like", "repost"):
|
||||
# First check for hydrated subject text (added by NotificationBuffer)
|
||||
post_text = g(notification, "_subject_text", "")
|
||||
|
||||
# Check if there's a reasonSubject with embedded post data
|
||||
if not post_text:
|
||||
reason_subject = g(notification, "reasonSubject") or g(notification, "reason_subject")
|
||||
if reason_subject:
|
||||
# Sometimes the subject post is embedded
|
||||
subject_record = g(reason_subject, "record", {})
|
||||
post_text = g(subject_record, "text", "")
|
||||
|
||||
# Check if there's subject post data in other locations
|
||||
if not post_text:
|
||||
subject = g(record, "subject", {})
|
||||
subject_text = g(subject, "text", "")
|
||||
if subject_text:
|
||||
post_text = subject_text
|
||||
|
||||
# Format: action text without username (username is already in column 0)
|
||||
if reason == "like":
|
||||
if post_text:
|
||||
text = _("has added to favorites: {status}").format(status=post_text)
|
||||
else:
|
||||
text = _("has added to favorites")
|
||||
elif reason == "repost":
|
||||
if post_text:
|
||||
text = _("has reposted: {status}").format(status=post_text)
|
||||
else:
|
||||
text = _("has reposted")
|
||||
elif reason == "follow":
|
||||
text = _("has followed you.")
|
||||
elif reason == "mention":
|
||||
if post_text:
|
||||
text = _("has mentioned you: {status}").format(status=post_text)
|
||||
else:
|
||||
text = _("has mentioned you")
|
||||
elif reason == "reply":
|
||||
if post_text:
|
||||
text = _("has replied: {status}").format(status=post_text)
|
||||
else:
|
||||
text = _("has replied")
|
||||
elif reason == "quote":
|
||||
if post_text:
|
||||
text = _("has quoted your post: {status}").format(status=post_text)
|
||||
else:
|
||||
text = _("has quoted your post")
|
||||
elif reason == "starterpack-joined":
|
||||
text = _("has joined your starter pack.")
|
||||
else:
|
||||
text = reason
|
||||
|
||||
# Date
|
||||
indexed_at = g(notification, "indexedAt", "") or g(notification, "indexed_at", "")
|
||||
ts_str = ""
|
||||
if indexed_at:
|
||||
try:
|
||||
ts = arrow.get(indexed_at)
|
||||
if relative_times:
|
||||
ts_str = ts.humanize(locale=languageHandler.curLang[:2])
|
||||
else:
|
||||
ts_str = ts.format(_("dddd, MMMM D, YYYY H:m"), locale=languageHandler.curLang[:2])
|
||||
except Exception:
|
||||
ts_str = str(indexed_at)[:16].replace("T", " ")
|
||||
|
||||
return [user_str, text, ts_str]
|
||||
|
||||
|
||||
def compose_user(user, db, settings, relative_times, show_screen_names=False, safe=True):
|
||||
"""
|
||||
Compose a Bluesky user profile for list display.
|
||||
Format matches Mastodon: single string with all info.
|
||||
"""
|
||||
def g(obj, key, default=None):
|
||||
if isinstance(obj, dict):
|
||||
return obj.get(key, default)
|
||||
return getattr(obj, key, default)
|
||||
|
||||
def resolve_profile(obj):
|
||||
if g(obj, "handle") or g(obj, "did"):
|
||||
return obj
|
||||
for key in ("subject", "actor", "profile", "user"):
|
||||
nested = g(obj, key)
|
||||
if nested and (g(nested, "handle") or g(nested, "did")):
|
||||
return nested
|
||||
return obj
|
||||
|
||||
profile = resolve_profile(user)
|
||||
handle = g(profile, "handle", "unknown")
|
||||
display_name = g(profile, "displayName") or g(profile, "display_name") or handle
|
||||
followers = g(profile, "followersCount") or g(profile, "followers_count") or 0
|
||||
following = g(profile, "followsCount") or g(profile, "follows_count") or 0
|
||||
posts = g(profile, "postsCount") or g(profile, "posts_count") or 0
|
||||
created_at = g(profile, "createdAt") or g(profile, "created_at")
|
||||
|
||||
ts = ""
|
||||
if created_at:
|
||||
try:
|
||||
original_date = arrow.get(created_at)
|
||||
if relative_times:
|
||||
ts = original_date.humanize(locale=languageHandler.curLang[:2])
|
||||
else:
|
||||
offset = db.get("utc_offset", 0) if isinstance(db, dict) else 0
|
||||
ts = original_date.shift(hours=offset).format(_("dddd, MMMM D, YYYY H:m"), locale=languageHandler.curLang[:2])
|
||||
except Exception:
|
||||
ts = ""
|
||||
|
||||
# Format like Mastodon: "Name (@handle). X followers, Y following, Z posts. Joined date"
|
||||
# Use the exact same translatable string as Mastodon (sessions/mastodon/compose.py)
|
||||
if not ts:
|
||||
ts = _("unknown")
|
||||
return [_("%s (@%s). %s followers, %s following, %s posts. Joined %s") % (display_name, handle, followers, following, posts, ts)]
|
||||
|
||||
|
||||
def compose_convo(convo, db, settings, relative_times, show_screen_names=False, safe=True):
|
||||
"""
|
||||
Compose a Bluesky chat conversation for list display.
|
||||
|
||||
Args:
|
||||
convo: Conversation dict or ATProto model
|
||||
db: Session database dict
|
||||
settings: Session settings
|
||||
relative_times: If True, use relative time formatting
|
||||
show_screen_names: If True, show only @handle
|
||||
safe: If True, handle exceptions gracefully
|
||||
|
||||
Returns:
|
||||
List of strings: [Participants, Last Message, Date]
|
||||
"""
|
||||
def g(obj, key, default=None):
|
||||
if isinstance(obj, dict):
|
||||
return obj.get(key, default)
|
||||
return getattr(obj, key, default)
|
||||
|
||||
members = g(convo, "members", [])
|
||||
self_did = db.get("user_id") if isinstance(db, dict) else None
|
||||
|
||||
# Build a local DID→name map from conversation members for sender resolution
|
||||
member_names = {}
|
||||
for m in members:
|
||||
did = g(m, "did", None)
|
||||
if did:
|
||||
name = g(m, "display_name") or g(m, "displayName") or g(m, "handle", "unknown")
|
||||
member_names[did] = name
|
||||
|
||||
# Get other participants (exclude self)
|
||||
others = []
|
||||
for m in members:
|
||||
did = g(m, "did", None)
|
||||
if self_did and did == self_did:
|
||||
continue
|
||||
label = member_names.get(did, "unknown") if did else g(m, "display_name") or g(m, "displayName") or g(m, "handle", "unknown")
|
||||
others.append(label)
|
||||
|
||||
if not others:
|
||||
others = [member_names.get(g(m, "did"), "unknown") if g(m, "did") else "unknown" for m in members]
|
||||
|
||||
participants = ", ".join(others)
|
||||
|
||||
# Last message
|
||||
last_msg_obj = g(convo, "lastMessage") or g(convo, "last_message")
|
||||
last_text = ""
|
||||
last_sender = ""
|
||||
|
||||
if last_msg_obj:
|
||||
last_text = g(last_msg_obj, "text", "")
|
||||
sender = g(last_msg_obj, "sender", None)
|
||||
if sender:
|
||||
last_sender = g(sender, "display_name") or g(sender, "displayName") or g(sender, "handle")
|
||||
if not last_sender:
|
||||
# Resolve DID via local member map
|
||||
sdid = g(sender, "did")
|
||||
if sdid:
|
||||
last_sender = member_names.get(sdid, "")
|
||||
if not last_sender:
|
||||
last_sender = sdid or ""
|
||||
|
||||
# Date
|
||||
date_str = ""
|
||||
if last_msg_obj:
|
||||
sent_at = g(last_msg_obj, "sentAt") or g(last_msg_obj, "sent_at")
|
||||
if sent_at:
|
||||
try:
|
||||
ts = arrow.get(sent_at)
|
||||
if relative_times:
|
||||
date_str = ts.humanize(locale=languageHandler.curLang[:2])
|
||||
else:
|
||||
date_str = ts.format(_("dddd, MMMM D, YYYY H:m"), locale=languageHandler.curLang[:2])
|
||||
except Exception:
|
||||
date_str = str(sent_at)[:16]
|
||||
|
||||
if last_sender and last_text:
|
||||
last_text = _("Last message from {user}: {text}").format(user=last_sender, text=last_text)
|
||||
elif last_text:
|
||||
last_text = _("Last message: {text}").format(text=last_text)
|
||||
|
||||
return [participants, last_text, date_str]
|
||||
|
||||
|
||||
def compose_chat_message(msg, db, settings, relative_times, show_screen_names=False, safe=True):
|
||||
"""
|
||||
Compose an individual chat message for display.
|
||||
|
||||
Args:
|
||||
msg: Chat message dict or ATProto model
|
||||
db: Session database dict
|
||||
settings: Session settings
|
||||
relative_times: If True, use relative time formatting
|
||||
show_screen_names: If True, show only @handle
|
||||
safe: If True, handle exceptions gracefully
|
||||
|
||||
Returns:
|
||||
List of strings: [Sender, Text, Date]
|
||||
"""
|
||||
def g(obj, key, default=None):
|
||||
if isinstance(obj, dict):
|
||||
return obj.get(key, default)
|
||||
return getattr(obj, key, default)
|
||||
|
||||
sender = g(msg, "sender", {})
|
||||
sender_did = g(sender, "did")
|
||||
handle = g(sender, "display_name") or g(sender, "displayName") or g(sender, "handle")
|
||||
if not handle and sender_did and isinstance(db, dict):
|
||||
# Look up DID in member maps stored by ChatBuffer
|
||||
for key, val in db.items():
|
||||
if key.endswith("_members") and isinstance(val, dict) and sender_did in val:
|
||||
handle = val[sender_did]
|
||||
break
|
||||
if not handle:
|
||||
handle = sender_did or "unknown"
|
||||
|
||||
text = g(msg, "text", "")
|
||||
|
||||
# Date
|
||||
sent_at = g(msg, "sentAt") or g(msg, "sent_at")
|
||||
date_str = ""
|
||||
if sent_at:
|
||||
try:
|
||||
ts = arrow.get(sent_at)
|
||||
if relative_times:
|
||||
date_str = ts.humanize(locale=languageHandler.curLang[:2])
|
||||
else:
|
||||
date_str = ts.format(_("dddd, MMMM D, YYYY H:m"), locale=languageHandler.curLang[:2])
|
||||
except Exception:
|
||||
date_str = str(sent_at)[:16]
|
||||
|
||||
return [handle, text, date_str]
|
||||
@@ -0,0 +1,901 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
import wx
|
||||
|
||||
from pubsub import pub
|
||||
|
||||
from sessions import base
|
||||
from sessions import session_exceptions as Exceptions
|
||||
import output
|
||||
import application
|
||||
import languageHandler
|
||||
|
||||
log = logging.getLogger("sessions.blueskiSession")
|
||||
|
||||
|
||||
class Language:
|
||||
"""Simple language object with code and name attributes, mimicking Mastodon.py format."""
|
||||
def __init__(self, code: str, name: str):
|
||||
self.code = code
|
||||
self.name = name
|
||||
|
||||
def __repr__(self):
|
||||
return f"Language({self.code}, {self.name})"
|
||||
|
||||
|
||||
def get_supported_languages():
|
||||
"""Returns the list of supported languages with translated names."""
|
||||
return [
|
||||
Language("", _("Not set")),
|
||||
Language("en", _("English")),
|
||||
Language("es", _("Spanish")),
|
||||
Language("fr", _("French")),
|
||||
Language("de", _("German")),
|
||||
Language("it", _("Italian")),
|
||||
Language("pt", _("Portuguese")),
|
||||
Language("ja", _("Japanese")),
|
||||
Language("ko", _("Korean")),
|
||||
Language("zh", _("Chinese")),
|
||||
Language("ru", _("Russian")),
|
||||
Language("ar", _("Arabic")),
|
||||
Language("hi", _("Hindi")),
|
||||
Language("nl", _("Dutch")),
|
||||
Language("pl", _("Polish")),
|
||||
Language("tr", _("Turkish")),
|
||||
Language("uk", _("Ukrainian")),
|
||||
Language("ca", _("Catalan")),
|
||||
Language("eu", _("Basque")),
|
||||
Language("gl", _("Galician")),
|
||||
]
|
||||
|
||||
# Optional import of atproto. Code handles absence gracefully.
|
||||
try:
|
||||
from atproto import Client as AtpClient # type: ignore
|
||||
except Exception: # ImportError or missing deps
|
||||
AtpClient = None # type: ignore
|
||||
|
||||
|
||||
class Session(base.baseSession):
|
||||
"""Minimal Bluesky (atproto) session for TWBlue.
|
||||
|
||||
Provides basic authorisation, login, and posting support to unblock
|
||||
the integration while keeping compatibility with TWBlue's session API.
|
||||
"""
|
||||
|
||||
name = "Bluesky"
|
||||
KIND = "blueski"
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(Session, self).__init__(*args, **kwargs)
|
||||
self.config_spec = "blueski.defaults"
|
||||
self.type = "blueski"
|
||||
self.char_limit = 300
|
||||
self.api = None
|
||||
self.poller = None
|
||||
self.supported_languages = get_supported_languages()
|
||||
self.default_language = languageHandler.curLang[:2]
|
||||
# Subscribe to pub/sub events from the poller
|
||||
pub.subscribe(self.on_notification, "blueski.notification_received")
|
||||
|
||||
def _ensure_settings_namespace(self) -> None:
|
||||
"""Migrate legacy atprotosocial settings to blueski namespace."""
|
||||
try:
|
||||
if not self.settings:
|
||||
return
|
||||
if self.settings.get("blueski") is None and self.settings.get("atprotosocial") is not None:
|
||||
self.settings["blueski"] = dict(self.settings["atprotosocial"])
|
||||
try:
|
||||
del self.settings["atprotosocial"]
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
self.settings.write()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
log.exception("Failed to migrate legacy Blueski settings")
|
||||
|
||||
def get_name(self):
|
||||
"""Return a human-friendly, stable account name for UI.
|
||||
|
||||
Prefer the user's handle if available so accounts are uniquely
|
||||
identifiable, falling back to a generic network name otherwise.
|
||||
"""
|
||||
self._ensure_settings_namespace()
|
||||
try:
|
||||
# Prefer runtime DB, then persisted settings, then SDK client
|
||||
handle = (
|
||||
self.db.get("user_name")
|
||||
or (self.settings and self.settings.get("blueski", {}).get("handle"))
|
||||
or (self.settings and self.settings.get("atprotosocial", {}).get("handle"))
|
||||
or (getattr(getattr(self, "api", None), "me", None) and self.api.me.handle)
|
||||
)
|
||||
if handle:
|
||||
return handle
|
||||
except Exception:
|
||||
pass
|
||||
return self.name
|
||||
|
||||
def _ensure_client(self):
|
||||
if AtpClient is None:
|
||||
raise RuntimeError(
|
||||
"The 'atproto' package is not installed. Install it to use Bluesky."
|
||||
)
|
||||
if self.api is None:
|
||||
self.api = AtpClient()
|
||||
return self.api
|
||||
|
||||
def login(self, verify_credentials=True):
|
||||
self._ensure_settings_namespace()
|
||||
if self.settings.get("blueski") is None:
|
||||
raise Exceptions.RequireCredentialsSessionError
|
||||
handle = self.settings["blueski"].get("handle")
|
||||
app_password = self.settings["blueski"].get("app_password")
|
||||
session_string = self.settings["blueski"].get("session_string")
|
||||
if not handle or (not app_password and not session_string):
|
||||
self.logged = False
|
||||
raise Exceptions.RequireCredentialsSessionError
|
||||
try:
|
||||
# Ensure db exists (can be set to None on logout paths)
|
||||
if not isinstance(self.db, dict):
|
||||
self.db = {}
|
||||
# Ensure general settings have a default for boost confirmations like Mastodon
|
||||
try:
|
||||
if "general" in self.settings and self.settings["general"].get("boost_mode") is None:
|
||||
self.settings["general"]["boost_mode"] = "ask"
|
||||
except Exception:
|
||||
pass
|
||||
api = self._ensure_client()
|
||||
# Prefer resuming session if we have one
|
||||
if session_string:
|
||||
try:
|
||||
api.import_session_string(session_string)
|
||||
except Exception:
|
||||
# Fall back to login below
|
||||
pass
|
||||
if not getattr(api, "me", None):
|
||||
# Fresh login
|
||||
api.login(handle, app_password)
|
||||
# Cache basics
|
||||
if getattr(api, "me", None) is None:
|
||||
raise RuntimeError("Bluesky SDK client has no 'me' after login")
|
||||
self.db["user_name"] = api.me.handle
|
||||
self.db["user_id"] = api.me.did
|
||||
# Persist DID in settings for session manager display
|
||||
self.settings["blueski"]["did"] = api.me.did
|
||||
# Export session for future reuse
|
||||
try:
|
||||
self.settings["blueski"]["session_string"] = api.export_session_string()
|
||||
except Exception:
|
||||
pass
|
||||
self.settings.write()
|
||||
self.logged = True
|
||||
log.debug("Logged in to Bluesky as %s", api.me.handle)
|
||||
except Exception as e:
|
||||
log.exception("Bluesky login failed")
|
||||
self.logged = False
|
||||
raise e
|
||||
|
||||
def authorise(self):
|
||||
self._ensure_settings_namespace()
|
||||
if self.logged:
|
||||
raise Exceptions.AlreadyAuthorisedError("Already authorised.")
|
||||
# Ask for handle
|
||||
dlg = wx.TextEntryDialog(
|
||||
None,
|
||||
_("Enter your Bluesky handle (e.g., username.bsky.social)"),
|
||||
_("Bluesky Login"),
|
||||
)
|
||||
if dlg.ShowModal() != wx.ID_OK:
|
||||
dlg.Destroy()
|
||||
return
|
||||
handle = dlg.GetValue().strip()
|
||||
dlg.Destroy()
|
||||
# Ask for app password
|
||||
pwd = wx.PasswordEntryDialog(
|
||||
None,
|
||||
_("Enter your Bluesky App Password (from Settings > App passwords)"),
|
||||
_("Bluesky Login"),
|
||||
)
|
||||
if pwd.ShowModal() != wx.ID_OK:
|
||||
pwd.Destroy()
|
||||
return
|
||||
app_password = pwd.GetValue().strip()
|
||||
pwd.Destroy()
|
||||
# Create session folder and config, then attempt login
|
||||
self.create_session_folder()
|
||||
self.get_configuration()
|
||||
self.settings["blueski"]["handle"] = handle
|
||||
self.settings["blueski"]["app_password"] = app_password
|
||||
self.settings.write()
|
||||
try:
|
||||
self.login()
|
||||
except Exceptions.RequireCredentialsSessionError:
|
||||
return
|
||||
except Exception:
|
||||
log.exception("Authorisation failed")
|
||||
wx.MessageBox(
|
||||
_("We could not log in to Bluesky. Please verify your handle and app password."),
|
||||
_("Login error"), wx.ICON_ERROR
|
||||
)
|
||||
return False
|
||||
return True
|
||||
|
||||
def get_message_url(self, message_id, context=None):
|
||||
# message_id may be full at:// URI or rkey
|
||||
self._ensure_settings_namespace()
|
||||
handle = self.db.get("user_name") or self.settings["blueski"].get("handle", "")
|
||||
rkey = message_id
|
||||
if isinstance(message_id, str) and message_id.startswith("at://"):
|
||||
parts = message_id.split("/")
|
||||
rkey = parts[-1]
|
||||
return f"https://bsky.app/profile/{handle}/post/{rkey}"
|
||||
|
||||
def send_message(self, message, files=None, reply_to=None, cw_text=None, is_sensitive=False, **kwargs):
|
||||
if not self.logged:
|
||||
raise Exceptions.NotLoggedSessionError("You are not logged in yet.")
|
||||
self._ensure_settings_namespace()
|
||||
try:
|
||||
api = self._ensure_client()
|
||||
# Basic text-only post for now. Attachments and CW can be extended later.
|
||||
# Prefer convenience if available
|
||||
uri = None
|
||||
text = message or ""
|
||||
# Naive CW handling: prepend CW label to text if provided
|
||||
if cw_text:
|
||||
text = f"CW: {cw_text}\n\n{text}" if text else f"CW: {cw_text}"
|
||||
|
||||
# Build base record
|
||||
record: dict[str, Any] = {
|
||||
"$type": "app.bsky.feed.post",
|
||||
"text": text,
|
||||
}
|
||||
|
||||
# Facets (Links and Mentions)
|
||||
try:
|
||||
facets = self._get_facets(text, api)
|
||||
if facets:
|
||||
record["facets"] = facets
|
||||
except:
|
||||
pass
|
||||
|
||||
# Labels (CW)
|
||||
if cw_text:
|
||||
record["labels"] = {
|
||||
"$type": "com.atproto.label.defs#selfLabels",
|
||||
"values": [{"val": "warn"}]
|
||||
}
|
||||
|
||||
# createdAt
|
||||
try:
|
||||
record["createdAt"] = api.get_current_time_iso()
|
||||
except Exception:
|
||||
pass
|
||||
# languages
|
||||
langs = kwargs.get("langs") or kwargs.get("languages")
|
||||
if isinstance(langs, (list, tuple)) and langs:
|
||||
record["langs"] = list(langs)
|
||||
|
||||
# Helper to build a StrongRef (uri+cid) for a given post URI
|
||||
def _get_strong_ref(uri: str):
|
||||
try:
|
||||
# Try typed models first
|
||||
posts_res = api.app.bsky.feed.get_posts({"uris": [uri]})
|
||||
posts = getattr(posts_res, "posts", None) or []
|
||||
except Exception:
|
||||
try:
|
||||
posts_res = api.app.bsky.feed.get_posts(uris=[uri])
|
||||
posts = getattr(posts_res, "posts", None) or []
|
||||
except Exception:
|
||||
posts = []
|
||||
if posts:
|
||||
post0 = posts[0]
|
||||
post_uri = getattr(post0, "uri", uri)
|
||||
post_cid = getattr(post0, "cid", None) or (post0.get("cid") if isinstance(post0, dict) else None)
|
||||
if post_cid:
|
||||
return {"uri": post_uri, "cid": post_cid}
|
||||
return None
|
||||
|
||||
# Upload images if provided
|
||||
embed_images = []
|
||||
if files:
|
||||
for f in files:
|
||||
path = f
|
||||
alt = ""
|
||||
if isinstance(f, dict):
|
||||
path = f.get("path") or f.get("file")
|
||||
alt = f.get("alt") or f.get("alt_text") or ""
|
||||
if not path:
|
||||
continue
|
||||
try:
|
||||
with open(path, "rb") as fp:
|
||||
data = fp.read()
|
||||
# Try typed upload
|
||||
try:
|
||||
up = api.com.atproto.repo.upload_blob(data)
|
||||
blob_ref = getattr(up, "blob", None) or getattr(up, "data", None) or up
|
||||
except Exception:
|
||||
# Some SDK variants expose upload via api.upload_blob
|
||||
up = api.upload_blob(data)
|
||||
blob_ref = getattr(up, "blob", None) or getattr(up, "data", None) or up
|
||||
if blob_ref:
|
||||
embed_images.append({
|
||||
"image": blob_ref,
|
||||
"alt": alt or "",
|
||||
})
|
||||
except Exception:
|
||||
log.exception("Error uploading media for Bluesky post")
|
||||
continue
|
||||
|
||||
# Quote post (takes precedence over images)
|
||||
quote_uri = kwargs.get("quote_uri") or kwargs.get("quote")
|
||||
if quote_uri:
|
||||
strong = _get_strong_ref(quote_uri)
|
||||
if strong:
|
||||
record["embed"] = {
|
||||
"$type": "app.bsky.embed.record",
|
||||
"record": strong,
|
||||
}
|
||||
embed_images = [] # Ignore images when quoting
|
||||
|
||||
if embed_images and not record.get("embed"):
|
||||
record["embed"] = {
|
||||
"$type": "app.bsky.embed.images",
|
||||
"images": embed_images,
|
||||
}
|
||||
|
||||
# Helper: normalize various incoming identifiers to an at:// URI
|
||||
def _normalize_to_uri(identifier: str) -> str | None:
|
||||
try:
|
||||
if not isinstance(identifier, str):
|
||||
return None
|
||||
if identifier.startswith("at://"):
|
||||
return identifier
|
||||
if "bsky.app/profile/" in identifier and "/post/" in identifier:
|
||||
# Accept full web URL and try to resolve via get_post_thread below
|
||||
return identifier
|
||||
# Accept bare rkey case by constructing a guess using own handle
|
||||
handle = self.db.get("user_name") or self.settings["blueski"].get("handle")
|
||||
did = self.db.get("user_id") or self.settings["blueski"].get("did")
|
||||
if handle and did and len(identifier) in (13, 14, 15):
|
||||
# rkey length is typically ~13 chars base32
|
||||
return f"at://{did}/app.bsky.feed.post/{identifier}"
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
# Reply-to handling (sets correct root/parent strong refs)
|
||||
if reply_to:
|
||||
# Resolve to proper at:// uri when possible
|
||||
reply_uri = _normalize_to_uri(reply_to) or reply_to
|
||||
reply_cid = kwargs.get("reply_to_cid")
|
||||
parent_ref = None
|
||||
if reply_uri and reply_cid:
|
||||
parent_ref = {"uri": reply_uri, "cid": reply_cid}
|
||||
if not parent_ref:
|
||||
parent_ref = _get_strong_ref(reply_uri)
|
||||
root_ref = parent_ref
|
||||
# Try to fetch thread to find actual root for deep replies
|
||||
try:
|
||||
# atproto SDK usually exposes get_post_thread
|
||||
thread_res = None
|
||||
try:
|
||||
thread_res = api.app.bsky.feed.get_post_thread({"uri": reply_uri})
|
||||
except Exception:
|
||||
# Try typed model call variant if available
|
||||
from atproto import models as at_models # type: ignore
|
||||
params = at_models.AppBskyFeedGetPostThread.Params(uri=reply_uri)
|
||||
thread_res = api.app.bsky.feed.get_post_thread(params)
|
||||
thread = getattr(thread_res, "thread", None)
|
||||
# Walk to the root if present
|
||||
node = thread
|
||||
while node and getattr(node, "parent", None):
|
||||
node = getattr(node, "parent")
|
||||
root_uri = getattr(node, "post", None)
|
||||
if root_uri:
|
||||
root_uri = getattr(root_uri, "uri", None)
|
||||
if root_uri and isinstance(root_uri, str):
|
||||
maybe_root = _get_strong_ref(root_uri)
|
||||
if maybe_root:
|
||||
root_ref = maybe_root
|
||||
except Exception:
|
||||
# If anything fails, keep parent as root for a simple two-level reply
|
||||
pass
|
||||
if parent_ref:
|
||||
record["reply"] = {
|
||||
"root": root_ref or parent_ref,
|
||||
"parent": parent_ref,
|
||||
}
|
||||
|
||||
# Fallback to convenience if available
|
||||
try:
|
||||
if hasattr(api, "send_post") and not embed_images and not langs and not cw_text:
|
||||
res = api.send_post(text)
|
||||
uri = getattr(res, "uri", None) or getattr(res, "cid", None)
|
||||
else:
|
||||
out = api.com.atproto.repo.create_record({
|
||||
"repo": api.me.did,
|
||||
"collection": "app.bsky.feed.post",
|
||||
"record": record,
|
||||
})
|
||||
uri = getattr(out, "uri", None)
|
||||
except Exception:
|
||||
log.exception("Error creating Bluesky post record")
|
||||
uri = None
|
||||
if not uri:
|
||||
raise RuntimeError("Post did not return a URI")
|
||||
|
||||
return uri
|
||||
except Exception:
|
||||
log.exception("Error sending Bluesky post")
|
||||
output.speak(_("An error occurred while posting to Bluesky."), True)
|
||||
return None
|
||||
|
||||
def _get_facets(self, text, api):
|
||||
facets = []
|
||||
# Mentions
|
||||
for m in re.finditer(r'@([a-zA-Z0-9.-]+)', text):
|
||||
handle = m.group(1)
|
||||
try:
|
||||
# We should probably cache this identity lookup
|
||||
res = api.com.atproto.identity.resolve_handle({'handle': handle})
|
||||
did = res.did
|
||||
facets.append({
|
||||
'index': {
|
||||
'byteStart': len(text[:m.start()].encode('utf-8')),
|
||||
'byteEnd': len(text[:m.end()].encode('utf-8'))
|
||||
},
|
||||
'features': [{'$type': 'app.bsky.richtext.facet#mention', 'did': did}]
|
||||
})
|
||||
except:
|
||||
continue
|
||||
# Links
|
||||
for m in re.finditer(r'(https?://[^\s]+)', text):
|
||||
url = m.group(1)
|
||||
facets.append({
|
||||
'index': {
|
||||
'byteStart': len(text[:m.start()].encode('utf-8')),
|
||||
'byteEnd': len(text[:m.end()].encode('utf-8'))
|
||||
},
|
||||
'features': [{'$type': 'app.bsky.richtext.facet#link', 'uri': url}]
|
||||
})
|
||||
return facets
|
||||
|
||||
def delete_post(self, uri: str) -> bool:
|
||||
"""Delete a post by its AT URI."""
|
||||
api = self._ensure_client()
|
||||
try:
|
||||
# at://did:plc:xxx/app.bsky.feed.post/rkey
|
||||
parts = uri.split("/")
|
||||
rkey = parts[-1]
|
||||
api.com.atproto.repo.delete_record({
|
||||
"repo": api.me.did,
|
||||
"collection": "app.bsky.feed.post",
|
||||
"rkey": rkey
|
||||
})
|
||||
return True
|
||||
except:
|
||||
log.exception("Error deleting Bluesky post")
|
||||
return False
|
||||
|
||||
def block_user(self, did: str) -> bool:
|
||||
"""Block a user by their DID."""
|
||||
api = self._ensure_client()
|
||||
try:
|
||||
api.com.atproto.repo.create_record({
|
||||
"repo": api.me.did,
|
||||
"collection": "app.bsky.graph.block",
|
||||
"record": {
|
||||
"$type": "app.bsky.graph.block",
|
||||
"subject": did,
|
||||
"createdAt": api.get_current_time_iso()
|
||||
}
|
||||
})
|
||||
return True
|
||||
except:
|
||||
log.exception("Error blocking Bluesky user")
|
||||
return False
|
||||
|
||||
def unblock_user(self, block_uri: str) -> bool:
|
||||
"""Unblock a user by the URI of the block record."""
|
||||
api = self._ensure_client()
|
||||
try:
|
||||
parts = block_uri.split("/")
|
||||
rkey = parts[-1]
|
||||
api.com.atproto.repo.delete_record({
|
||||
"repo": api.me.did,
|
||||
"collection": "app.bsky.graph.block",
|
||||
"rkey": rkey
|
||||
})
|
||||
return True
|
||||
except:
|
||||
log.exception("Error unblocking Bluesky user")
|
||||
return False
|
||||
|
||||
def get_profile(self, actor: str) -> Any:
|
||||
api = self._ensure_client()
|
||||
try:
|
||||
return api.app.bsky.actor.get_profile({"actor": actor})
|
||||
except Exception:
|
||||
log.exception("Error fetching Bluesky profile for %s", actor)
|
||||
return None
|
||||
|
||||
def get_profiles(self, actors: list[str]) -> dict[str, Any]:
|
||||
api = self._ensure_client()
|
||||
if not actors:
|
||||
return {"items": []}
|
||||
# API limit is 25 actors per request, batch if needed
|
||||
all_profiles = []
|
||||
batch_size = 25
|
||||
for i in range(0, len(actors), batch_size):
|
||||
batch = actors[i:i + batch_size]
|
||||
try:
|
||||
res = api.app.bsky.actor.get_profiles({"actors": batch})
|
||||
profiles = getattr(res, "profiles", []) or []
|
||||
all_profiles.extend(profiles)
|
||||
except Exception:
|
||||
log.exception("Error fetching Bluesky profiles batch")
|
||||
return {"items": all_profiles}
|
||||
|
||||
def get_post_likes(self, uri: str, limit: int = 50, cursor: str | None = None) -> dict[str, Any]:
|
||||
api = self._ensure_client()
|
||||
try:
|
||||
params = {"uri": uri, "limit": limit}
|
||||
if cursor:
|
||||
params["cursor"] = cursor
|
||||
res = api.app.bsky.feed.get_likes(params)
|
||||
return {"items": getattr(res, "likes", []) or [], "cursor": getattr(res, "cursor", None)}
|
||||
except Exception:
|
||||
log.exception("Error fetching Bluesky likes for %s", uri)
|
||||
return {"items": [], "cursor": None}
|
||||
|
||||
def get_post_reposts(self, uri: str, limit: int = 50, cursor: str | None = None) -> dict[str, Any]:
|
||||
api = self._ensure_client()
|
||||
try:
|
||||
params = {"uri": uri, "limit": limit}
|
||||
if cursor:
|
||||
params["cursor"] = cursor
|
||||
# SDK uses get_reposted_by (camel or snake)
|
||||
feed = api.app.bsky.feed
|
||||
if hasattr(feed, "get_reposted_by"):
|
||||
res = feed.get_reposted_by(params)
|
||||
else:
|
||||
res = feed.get_repostedBy(params)
|
||||
return {"items": getattr(res, "reposted_by", None) or getattr(res, "repostedBy", None) or getattr(res, "reposted_by", []) or [], "cursor": getattr(res, "cursor", None)}
|
||||
except Exception:
|
||||
log.exception("Error fetching Bluesky reposts for %s", uri)
|
||||
return {"items": [], "cursor": None}
|
||||
|
||||
def follow_user(self, did: str) -> bool:
|
||||
api = self._ensure_client()
|
||||
try:
|
||||
api.com.atproto.repo.create_record({
|
||||
"repo": api.me.did,
|
||||
"collection": "app.bsky.graph.follow",
|
||||
"record": {
|
||||
"$type": "app.bsky.graph.follow",
|
||||
"subject": did,
|
||||
"createdAt": api.get_current_time_iso()
|
||||
}
|
||||
})
|
||||
return True
|
||||
except Exception:
|
||||
log.exception("Error following Bluesky user")
|
||||
return False
|
||||
|
||||
def unfollow_user(self, follow_uri: str) -> bool:
|
||||
api = self._ensure_client()
|
||||
try:
|
||||
parts = follow_uri.split("/")
|
||||
rkey = parts[-1]
|
||||
api.com.atproto.repo.delete_record({
|
||||
"repo": api.me.did,
|
||||
"collection": "app.bsky.graph.follow",
|
||||
"rkey": rkey
|
||||
})
|
||||
return True
|
||||
except Exception:
|
||||
log.exception("Error unfollowing Bluesky user")
|
||||
return False
|
||||
|
||||
def mute_user(self, did: str) -> bool:
|
||||
api = self._ensure_client()
|
||||
try:
|
||||
graph = api.app.bsky.graph
|
||||
if hasattr(graph, "mute_actor"):
|
||||
graph.mute_actor({"actor": did})
|
||||
elif hasattr(graph, "muteActor"):
|
||||
graph.muteActor({"actor": did})
|
||||
else:
|
||||
return False
|
||||
return True
|
||||
except Exception:
|
||||
log.exception("Error muting Bluesky user")
|
||||
return False
|
||||
|
||||
def unmute_user(self, did: str) -> bool:
|
||||
api = self._ensure_client()
|
||||
try:
|
||||
graph = api.app.bsky.graph
|
||||
if hasattr(graph, "unmute_actor"):
|
||||
graph.unmute_actor({"actor": did})
|
||||
elif hasattr(graph, "unmuteActor"):
|
||||
graph.unmuteActor({"actor": did})
|
||||
else:
|
||||
return False
|
||||
return True
|
||||
except Exception:
|
||||
log.exception("Error unmuting Bluesky user")
|
||||
return False
|
||||
|
||||
def repost(self, post_uri: str, post_cid: str | None = None) -> str | None:
|
||||
"""Create a simple repost of a given post. Returns URI of the repost record or None."""
|
||||
if not self.logged:
|
||||
raise Exceptions.NotLoggedSessionError("You are not logged in yet.")
|
||||
try:
|
||||
api = self._ensure_client()
|
||||
|
||||
def _get_strong_ref(uri: str):
|
||||
try:
|
||||
posts_res = api.app.bsky.feed.get_posts({"uris": [uri]})
|
||||
posts = getattr(posts_res, "posts", None) or []
|
||||
except Exception:
|
||||
try:
|
||||
posts_res = api.app.bsky.feed.get_posts(uris=[uri])
|
||||
posts = getattr(posts_res, "posts", None) or []
|
||||
except Exception:
|
||||
posts = []
|
||||
if posts:
|
||||
post0 = posts[0]
|
||||
s_uri = getattr(post0, "uri", uri)
|
||||
s_cid = getattr(post0, "cid", None) or (post0.get("cid") if isinstance(post0, dict) else None)
|
||||
if s_cid:
|
||||
return {"uri": s_uri, "cid": s_cid}
|
||||
return None
|
||||
|
||||
if not post_cid:
|
||||
strong = _get_strong_ref(post_uri)
|
||||
if not strong:
|
||||
return None
|
||||
post_uri = strong["uri"]
|
||||
post_cid = strong["cid"]
|
||||
|
||||
out = api.com.atproto.repo.create_record({
|
||||
"repo": api.me.did,
|
||||
"collection": "app.bsky.feed.repost",
|
||||
"record": {
|
||||
"$type": "app.bsky.feed.repost",
|
||||
"subject": {"uri": post_uri, "cid": post_cid},
|
||||
"createdAt": getattr(api, "get_current_time_iso", lambda: None)() or None,
|
||||
},
|
||||
})
|
||||
return getattr(out, "uri", None)
|
||||
except Exception:
|
||||
log.exception("Error creating Bluesky repost record")
|
||||
return None
|
||||
|
||||
def like(self, post_uri: str, post_cid: str | None = None) -> str | None:
|
||||
"""Create a like for a given post."""
|
||||
if not self.logged:
|
||||
raise Exceptions.NotLoggedSessionError("You are not logged in yet.")
|
||||
try:
|
||||
api = self._ensure_client()
|
||||
|
||||
# Resolve strong ref if needed
|
||||
def _get_strong_ref(uri: str):
|
||||
try:
|
||||
posts_res = api.app.bsky.feed.get_posts({"uris": [uri]})
|
||||
posts = getattr(posts_res, "posts", None) or []
|
||||
except Exception:
|
||||
try: posts_res = api.app.bsky.feed.get_posts(uris=[uri])
|
||||
except: posts_res = None
|
||||
posts = getattr(posts_res, "posts", None) or []
|
||||
if posts:
|
||||
p = posts[0]
|
||||
return {"uri": getattr(p, "uri", uri), "cid": getattr(p, "cid", None)}
|
||||
return None
|
||||
|
||||
if not post_cid:
|
||||
strong = _get_strong_ref(post_uri)
|
||||
if not strong: return None
|
||||
post_uri = strong["uri"]
|
||||
post_cid = strong["cid"]
|
||||
|
||||
out = api.com.atproto.repo.create_record({
|
||||
"repo": api.me.did,
|
||||
"collection": "app.bsky.feed.like",
|
||||
"record": {
|
||||
"$type": "app.bsky.feed.like",
|
||||
"subject": {"uri": post_uri, "cid": post_cid},
|
||||
"createdAt": getattr(api, "get_current_time_iso", lambda: None)() or None,
|
||||
},
|
||||
})
|
||||
return getattr(out, "uri", None)
|
||||
except Exception:
|
||||
log.exception("Error creating Bluesky like")
|
||||
return None
|
||||
|
||||
def get_followers(self, actor: str | None = None, limit: int = 50, cursor: str | None = None) -> dict[str, Any]:
|
||||
api = self._ensure_client()
|
||||
actor = actor or api.me.did
|
||||
res = api.app.bsky.graph.get_followers({"actor": actor, "limit": limit, "cursor": cursor})
|
||||
return {"items": res.followers, "cursor": res.cursor}
|
||||
|
||||
def get_follows(self, actor: str | None = None, limit: int = 50, cursor: str | None = None) -> dict[str, Any]:
|
||||
api = self._ensure_client()
|
||||
actor = actor or api.me.did
|
||||
res = api.app.bsky.graph.get_follows({"actor": actor, "limit": limit, "cursor": cursor})
|
||||
return {"items": res.follows, "cursor": res.cursor}
|
||||
|
||||
def get_blocks(self, limit: int = 50, cursor: str | None = None) -> dict[str, Any]:
|
||||
api = self._ensure_client()
|
||||
res = api.app.bsky.graph.get_blocks({"limit": limit, "cursor": cursor})
|
||||
return {"items": res.blocks, "cursor": res.cursor}
|
||||
|
||||
def list_convos(self, limit: int = 50, cursor: str | None = None) -> dict[str, Any]:
|
||||
api = self._ensure_client()
|
||||
# Chat API requires using the chat proxy
|
||||
dm_client = api.with_bsky_chat_proxy()
|
||||
dm = dm_client.chat.bsky.convo
|
||||
params = {"limit": limit}
|
||||
if cursor:
|
||||
params["cursor"] = cursor
|
||||
try:
|
||||
res = dm.list_convos(params)
|
||||
return {"items": res.convos, "cursor": getattr(res, "cursor", None)}
|
||||
except Exception:
|
||||
log.exception("Error listing conversations")
|
||||
return {"items": [], "cursor": None}
|
||||
|
||||
def get_convo(self, convo_id: str):
|
||||
"""Fetch a single conversation by ID, returning the convo object or None."""
|
||||
api = self._ensure_client()
|
||||
dm_client = api.with_bsky_chat_proxy()
|
||||
dm = dm_client.chat.bsky.convo
|
||||
try:
|
||||
res = dm.get_convo({"convoId": convo_id})
|
||||
return res.convo
|
||||
except Exception:
|
||||
log.exception("Error fetching conversation %s", convo_id)
|
||||
return None
|
||||
|
||||
def get_convo_messages(self, convo_id: str, limit: int = 50, cursor: str | None = None) -> dict[str, Any]:
|
||||
api = self._ensure_client()
|
||||
dm_client = api.with_bsky_chat_proxy()
|
||||
dm = dm_client.chat.bsky.convo
|
||||
params = {"convoId": convo_id, "limit": limit}
|
||||
if cursor:
|
||||
params["cursor"] = cursor
|
||||
try:
|
||||
res = dm.get_messages(params)
|
||||
return {"items": res.messages, "cursor": getattr(res, "cursor", None)}
|
||||
except Exception:
|
||||
log.exception("Error getting conversation messages")
|
||||
return {"items": [], "cursor": None}
|
||||
|
||||
def send_chat_message(self, convo_id: str, text: str) -> Any:
|
||||
api = self._ensure_client()
|
||||
dm_client = api.with_bsky_chat_proxy()
|
||||
dm = dm_client.chat.bsky.convo
|
||||
try:
|
||||
return dm.send_message({
|
||||
"convoId": convo_id,
|
||||
"message": {
|
||||
"text": text
|
||||
}
|
||||
})
|
||||
except Exception:
|
||||
log.exception("Error sending chat message")
|
||||
raise
|
||||
|
||||
def get_or_create_convo(self, members: list[str]) -> dict[str, Any] | None:
|
||||
"""Get or create a conversation with the given members (DIDs)."""
|
||||
api = self._ensure_client()
|
||||
dm_client = api.with_bsky_chat_proxy()
|
||||
dm = dm_client.chat.bsky.convo
|
||||
try:
|
||||
res = dm.get_convo_for_members({"members": members})
|
||||
return res.convo
|
||||
except Exception:
|
||||
log.exception("Error getting/creating conversation")
|
||||
return None
|
||||
|
||||
# Streaming/Polling methods
|
||||
|
||||
def start_streaming(self):
|
||||
"""Start the background poller for notifications."""
|
||||
if not self.logged:
|
||||
log.debug("Cannot start Bluesky poller: not logged in.")
|
||||
return
|
||||
|
||||
if self.poller is not None and self.poller.is_alive():
|
||||
log.debug("Bluesky poller already running for %s", self.get_name())
|
||||
return
|
||||
|
||||
try:
|
||||
from sessions.blueski.streaming import BlueskyPoller
|
||||
poll_interval = 60
|
||||
try:
|
||||
poll_interval = self.settings["general"].get("update_period", 60)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
self.poller = BlueskyPoller(
|
||||
session=self,
|
||||
session_name=self.get_name(),
|
||||
poll_interval=poll_interval
|
||||
)
|
||||
self.poller.start()
|
||||
log.info("Started Bluesky poller for session %s", self.get_name())
|
||||
except Exception:
|
||||
log.exception("Failed to start Bluesky poller")
|
||||
|
||||
def stop_streaming(self):
|
||||
"""Stop the background poller."""
|
||||
if self.poller is not None:
|
||||
self.poller.stop()
|
||||
self.poller = None
|
||||
log.info("Stopped Bluesky poller for session %s", self.get_name())
|
||||
|
||||
def on_notification(self, notification, session_name):
|
||||
"""Handle notification received from the poller via pub/sub."""
|
||||
# Discard if notification is for a different session
|
||||
if self.get_name() != session_name:
|
||||
return
|
||||
|
||||
# Add notification to the notifications buffer
|
||||
try:
|
||||
num = self.order_buffer("notifications", [notification])
|
||||
if num > 0:
|
||||
pub.sendMessage(
|
||||
"blueski.new_item",
|
||||
session_name=self.get_name(),
|
||||
item=notification,
|
||||
_buffers=["notifications"]
|
||||
)
|
||||
except Exception:
|
||||
log.exception("Error processing Bluesky notification")
|
||||
|
||||
def order_buffer(self, buffer_name, items):
|
||||
"""Add items to the specified buffer's database.
|
||||
|
||||
Returns the number of new items added.
|
||||
"""
|
||||
if buffer_name not in self.db:
|
||||
self.db[buffer_name] = []
|
||||
|
||||
# Get existing URIs to avoid duplicates
|
||||
existing_uris = set()
|
||||
for item in self.db[buffer_name]:
|
||||
uri = None
|
||||
if isinstance(item, dict):
|
||||
uri = item.get("uri")
|
||||
else:
|
||||
uri = getattr(item, "uri", None)
|
||||
if uri:
|
||||
existing_uris.add(uri)
|
||||
|
||||
# Add new items
|
||||
new_count = 0
|
||||
for item in items:
|
||||
uri = None
|
||||
if isinstance(item, dict):
|
||||
uri = item.get("uri")
|
||||
else:
|
||||
uri = getattr(item, "uri", None)
|
||||
|
||||
if uri and uri in existing_uris:
|
||||
continue
|
||||
|
||||
if uri:
|
||||
existing_uris.add(uri)
|
||||
|
||||
# Insert at beginning (newest first)
|
||||
self.db[buffer_name].insert(0, item)
|
||||
new_count += 1
|
||||
|
||||
return new_count
|
||||
@@ -0,0 +1,196 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Bluesky polling-based update system for TWBlue.
|
||||
|
||||
Since Bluesky's Firehose requires complex CAR/CBOR decoding and filtering
|
||||
of millions of events, we use a polling approach instead of true streaming.
|
||||
This matches the existing start_stream() pattern used by buffers.
|
||||
|
||||
Events are published via pub/sub to maintain consistency with Mastodon's
|
||||
streaming implementation.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from pubsub import pub
|
||||
|
||||
log = logging.getLogger("sessions.blueski.streaming")
|
||||
|
||||
|
||||
class BlueskyPoller:
|
||||
"""
|
||||
Polling-based update system for Bluesky.
|
||||
|
||||
Periodically checks for new notifications and publishes them via pub/sub.
|
||||
This provides a similar interface to Mastodon's StreamListener but uses
|
||||
polling instead of WebSocket streaming.
|
||||
"""
|
||||
|
||||
def __init__(self, session, session_name, poll_interval=60):
|
||||
"""
|
||||
Initialize the poller.
|
||||
|
||||
Args:
|
||||
session: The Bluesky session instance
|
||||
session_name: Unique identifier for this session (for pub/sub routing)
|
||||
poll_interval: Seconds between API polls (default 60, min 30)
|
||||
"""
|
||||
self.session = session
|
||||
self.session_name = session_name
|
||||
self.poll_interval = max(30, poll_interval) # Minimum 30 seconds to respect rate limits
|
||||
|
||||
self._stop_event = threading.Event()
|
||||
self._thread = None
|
||||
self._last_notification_cursor = None
|
||||
self._last_seen_notification_uri = None
|
||||
|
||||
def start(self):
|
||||
"""Start the polling thread."""
|
||||
if self._thread is not None and self._thread.is_alive():
|
||||
log.warning(f"Bluesky poller for {self.session_name} is already running.")
|
||||
return
|
||||
|
||||
self._stop_event.clear()
|
||||
self._thread = threading.Thread(
|
||||
target=self._poll_loop,
|
||||
name=f"BlueskyPoller-{self.session_name}",
|
||||
daemon=True
|
||||
)
|
||||
self._thread.start()
|
||||
log.info(f"Bluesky poller started for {self.session_name} (interval: {self.poll_interval}s)")
|
||||
|
||||
def stop(self):
|
||||
"""Stop the polling thread."""
|
||||
if self._thread is None:
|
||||
return
|
||||
|
||||
self._stop_event.set()
|
||||
self._thread.join(timeout=5)
|
||||
self._thread = None
|
||||
log.info(f"Bluesky poller stopped for {self.session_name}")
|
||||
|
||||
def is_alive(self):
|
||||
"""Check if the polling thread is running."""
|
||||
return self._thread is not None and self._thread.is_alive()
|
||||
|
||||
def _poll_loop(self):
|
||||
"""Main polling loop running in background thread."""
|
||||
log.debug(f"Polling loop started for {self.session_name}")
|
||||
|
||||
# Initial delay to let the app fully initialize
|
||||
time.sleep(5)
|
||||
|
||||
while not self._stop_event.is_set():
|
||||
try:
|
||||
self._check_notifications()
|
||||
except Exception as e:
|
||||
log.exception(f"Error in Bluesky polling loop for {self.session_name}: {e}")
|
||||
|
||||
# Wait for next poll interval, checking stop event periodically
|
||||
for _ in range(self.poll_interval):
|
||||
if self._stop_event.is_set():
|
||||
break
|
||||
time.sleep(1)
|
||||
|
||||
log.debug(f"Polling loop ended for {self.session_name}")
|
||||
|
||||
def _check_notifications(self):
|
||||
"""Check for new notifications and publish events."""
|
||||
if not self.session.logged:
|
||||
return
|
||||
|
||||
try:
|
||||
api = self.session._ensure_client()
|
||||
if not api:
|
||||
return
|
||||
|
||||
# Fetch recent notifications
|
||||
res = api.app.bsky.notification.list_notifications({"limit": 20})
|
||||
notifications = getattr(res, "notifications", [])
|
||||
|
||||
if not notifications:
|
||||
return
|
||||
|
||||
# Track which notifications are new
|
||||
new_notifications = []
|
||||
newest_uri = None
|
||||
|
||||
for notif in notifications:
|
||||
uri = getattr(notif, "uri", None)
|
||||
if not uri:
|
||||
continue
|
||||
|
||||
# First time running - just record the newest and don't flood
|
||||
if self._last_seen_notification_uri is None:
|
||||
newest_uri = uri
|
||||
break
|
||||
|
||||
# Check if we've seen this notification before
|
||||
if uri == self._last_seen_notification_uri:
|
||||
break
|
||||
|
||||
new_notifications.append(notif)
|
||||
if newest_uri is None:
|
||||
newest_uri = uri
|
||||
|
||||
# Update last seen
|
||||
if newest_uri:
|
||||
self._last_seen_notification_uri = newest_uri
|
||||
|
||||
# Publish new notifications (in reverse order so oldest first)
|
||||
for notif in reversed(new_notifications):
|
||||
self._publish_notification(notif)
|
||||
|
||||
except Exception as e:
|
||||
log.debug(f"Error checking notifications for {self.session_name}: {e}")
|
||||
|
||||
def _publish_notification(self, notification):
|
||||
"""Publish a notification event via pub/sub."""
|
||||
try:
|
||||
reason = getattr(notification, "reason", "unknown")
|
||||
log.debug(f"Publishing Bluesky notification: {reason} for {self.session_name}")
|
||||
|
||||
pub.sendMessage(
|
||||
"blueski.notification_received",
|
||||
notification=notification,
|
||||
session_name=self.session_name
|
||||
)
|
||||
|
||||
# Also publish specific events for certain notification types
|
||||
if reason == "mention":
|
||||
pub.sendMessage(
|
||||
"blueski.mention_received",
|
||||
notification=notification,
|
||||
session_name=self.session_name
|
||||
)
|
||||
elif reason == "reply":
|
||||
pub.sendMessage(
|
||||
"blueski.reply_received",
|
||||
notification=notification,
|
||||
session_name=self.session_name
|
||||
)
|
||||
elif reason == "follow":
|
||||
pub.sendMessage(
|
||||
"blueski.follow_received",
|
||||
notification=notification,
|
||||
session_name=self.session_name
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
log.exception(f"Error publishing notification event: {e}")
|
||||
|
||||
|
||||
def create_poller(session, session_name, poll_interval=60):
|
||||
"""
|
||||
Factory function to create a BlueskyPoller instance.
|
||||
|
||||
Args:
|
||||
session: The Bluesky session instance
|
||||
session_name: Unique identifier for this session
|
||||
poll_interval: Seconds between polls (default 60)
|
||||
|
||||
Returns:
|
||||
BlueskyPoller instance
|
||||
"""
|
||||
return BlueskyPoller(session, session_name, poll_interval)
|
||||
@@ -0,0 +1,307 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import arrow
|
||||
import languageHandler
|
||||
from string import Template
|
||||
from sessions.blueski import utils
|
||||
|
||||
|
||||
post_variables = [
|
||||
"date",
|
||||
"display_name",
|
||||
"screen_name",
|
||||
"reply_to",
|
||||
"source",
|
||||
"lang",
|
||||
"safe_text",
|
||||
"text",
|
||||
"image_descriptions",
|
||||
"visibility",
|
||||
"pinned",
|
||||
]
|
||||
person_variables = [
|
||||
"display_name",
|
||||
"screen_name",
|
||||
"description",
|
||||
"followers",
|
||||
"following",
|
||||
"favorites",
|
||||
"posts",
|
||||
"created_at",
|
||||
]
|
||||
notification_variables = ["display_name", "screen_name", "text", "date"]
|
||||
|
||||
|
||||
def _g(obj, key, default=None):
|
||||
if isinstance(obj, dict):
|
||||
return obj.get(key, default)
|
||||
return getattr(obj, key, default)
|
||||
|
||||
|
||||
def _extract_labels(obj):
|
||||
labels = _g(obj, "labels", None)
|
||||
if labels is None:
|
||||
return []
|
||||
if isinstance(labels, dict):
|
||||
return labels.get("values", []) or []
|
||||
if isinstance(labels, list):
|
||||
return labels
|
||||
return []
|
||||
|
||||
|
||||
def _extract_cw_text(post, record):
|
||||
labels = _extract_labels(post) + _extract_labels(record)
|
||||
for label in labels:
|
||||
val = _g(label, "val", "")
|
||||
if val == "warn":
|
||||
return _("Sensitive Content")
|
||||
if isinstance(val, str) and val.startswith("warn:"):
|
||||
return val.split("warn:", 1)[-1].strip()
|
||||
return ""
|
||||
|
||||
|
||||
def _extract_image_descriptions(post, record):
|
||||
def collect_images(embed):
|
||||
if not embed:
|
||||
return []
|
||||
etype = _g(embed, "$type") or _g(embed, "py_type") or ""
|
||||
if "recordWithMedia" in etype:
|
||||
media = _g(embed, "media")
|
||||
mtype = _g(media, "$type") or _g(media, "py_type") or ""
|
||||
if "images" in mtype:
|
||||
return list(_g(media, "images", []) or [])
|
||||
return []
|
||||
if "images" in etype:
|
||||
return list(_g(embed, "images", []) or [])
|
||||
return []
|
||||
|
||||
images = []
|
||||
images.extend(collect_images(_g(post, "embed")))
|
||||
if not images:
|
||||
images.extend(collect_images(_g(record, "embed")))
|
||||
|
||||
descriptions = []
|
||||
for idx, img in enumerate(images, start=1):
|
||||
alt = _g(img, "alt", "") or ""
|
||||
if alt:
|
||||
descriptions.append(_("Media description {index}: {alt}").format(index=idx, alt=alt))
|
||||
return "\n".join(descriptions)
|
||||
|
||||
|
||||
def process_date(field, relative_times=True, offset_hours=0):
|
||||
original_date = arrow.get(field)
|
||||
if relative_times:
|
||||
return original_date.humanize(locale=languageHandler.curLang[:2])
|
||||
return original_date.shift(hours=offset_hours).format(_("dddd, MMMM D, YYYY H:m:s"), locale=languageHandler.curLang[:2])
|
||||
|
||||
|
||||
def _extract_link_info(post, record):
|
||||
"""Extract link information from post embeds and facets."""
|
||||
embed = _g(post, "embed")
|
||||
if not embed:
|
||||
return None
|
||||
|
||||
etype = _g(embed, "$type") or _g(embed, "py_type") or ""
|
||||
|
||||
# Direct external embed
|
||||
if "external" in etype.lower():
|
||||
ext = _g(embed, "external", {})
|
||||
title = _g(ext, "title", "")
|
||||
if title:
|
||||
return title
|
||||
|
||||
# RecordWithMedia with external
|
||||
if "recordWithMedia" in etype:
|
||||
media = _g(embed, "media", {})
|
||||
mtype = _g(media, "$type") or _g(media, "py_type") or ""
|
||||
if "external" in mtype.lower():
|
||||
ext = _g(media, "external", {})
|
||||
title = _g(ext, "title", "")
|
||||
if title:
|
||||
return title
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def render_post(post, template, settings, relative_times=False, offset_hours=0):
|
||||
actual_post = _g(post, "post", post)
|
||||
record = _g(actual_post, "record") or _g(post, "record") or {}
|
||||
author = _g(actual_post, "author") or _g(post, "author") or {}
|
||||
|
||||
reason = _g(post, "reason")
|
||||
is_repost = False
|
||||
reposter = None
|
||||
if reason:
|
||||
rtype = _g(reason, "$type") or _g(reason, "py_type") or ""
|
||||
if "reasonRepost" in rtype:
|
||||
is_repost = True
|
||||
reposter = _g(reason, "by")
|
||||
|
||||
if is_repost and reposter:
|
||||
display_name = _g(reposter, "displayName") or _g(reposter, "display_name") or _g(reposter, "handle", "")
|
||||
screen_name = _g(reposter, "handle", "")
|
||||
else:
|
||||
display_name = _g(author, "displayName") or _g(author, "display_name") or _g(author, "handle", "")
|
||||
screen_name = _g(author, "handle", "")
|
||||
|
||||
text = _g(record, "text", "") or ""
|
||||
if is_repost:
|
||||
original_handle = _g(author, "handle", "")
|
||||
text = _("Reposted from @{handle}: {text}").format(handle=original_handle, text=text)
|
||||
|
||||
quote_info = utils.extract_quoted_post_info(post)
|
||||
if quote_info:
|
||||
if quote_info["kind"] == "not_found":
|
||||
text += f" [{_('Quoted post not found')}]"
|
||||
elif quote_info["kind"] == "blocked":
|
||||
text += f" [{_('Quoted post blocked')}]"
|
||||
elif quote_info["kind"] == "feed":
|
||||
text += f" [{_('Quoting Feed')}: {quote_info.get('feed_name', 'Feed')}]"
|
||||
else:
|
||||
q_handle = quote_info.get("handle", "unknown")
|
||||
q_text = quote_info.get("text", "")
|
||||
if q_text:
|
||||
text += " " + _("Quoting @{handle}: {text}").format(handle=q_handle, text=q_text)
|
||||
else:
|
||||
text += " " + _("Quoting @{handle}").format(handle=q_handle)
|
||||
|
||||
# Add link indicator for external embeds
|
||||
link_title = _extract_link_info(actual_post, record)
|
||||
if link_title:
|
||||
text += f" [{_('Link')}: {link_title}]"
|
||||
|
||||
reply_to_handle = utils.extract_reply_to_handle(post)
|
||||
reply_to = ""
|
||||
if reply_to_handle:
|
||||
reply_to = _("Replying to @{handle}. ").format(handle=reply_to_handle)
|
||||
|
||||
cw_text = _extract_cw_text(actual_post, record)
|
||||
safe_text = text
|
||||
if cw_text:
|
||||
# Include link info in safe_text even with content warning
|
||||
if link_title:
|
||||
safe_text = _("Content warning: {cw}").format(cw=cw_text) + f" [{_('Link')}: {link_title}]"
|
||||
else:
|
||||
safe_text = _("Content warning: {cw}").format(cw=cw_text)
|
||||
|
||||
# Backward compatibility: older user templates may not include $reply_to.
|
||||
# In that case, prepend the reply marker directly so users still get context.
|
||||
if reply_to and "$reply_to" not in template:
|
||||
text = reply_to + text
|
||||
safe_text = reply_to + safe_text
|
||||
reply_to = ""
|
||||
|
||||
created_at = _g(record, "createdAt") or _g(record, "created_at")
|
||||
indexed_at = _g(actual_post, "indexedAt") or _g(actual_post, "indexed_at")
|
||||
date_field = created_at or indexed_at
|
||||
date = process_date(date_field, relative_times, offset_hours) if date_field else ""
|
||||
|
||||
langs = _g(record, "langs") or _g(record, "languages") or []
|
||||
lang = langs[0] if isinstance(langs, list) and langs else ""
|
||||
|
||||
image_descriptions = _extract_image_descriptions(actual_post, record)
|
||||
|
||||
available_data = dict(
|
||||
date=date,
|
||||
display_name=display_name,
|
||||
screen_name=screen_name,
|
||||
reply_to=reply_to,
|
||||
source="Bluesky",
|
||||
lang=lang,
|
||||
safe_text=safe_text,
|
||||
text=text,
|
||||
image_descriptions=image_descriptions,
|
||||
visibility=_("Public"),
|
||||
pinned="",
|
||||
)
|
||||
return Template(_(template)).safe_substitute(**available_data)
|
||||
|
||||
|
||||
def render_user(user, template, settings, relative_times=True, offset_hours=0):
|
||||
# Resolve nested profile structure (subject, actor, profile, user)
|
||||
def resolve_profile(obj):
|
||||
if _g(obj, "handle") or _g(obj, "did"):
|
||||
return obj
|
||||
for key in ("subject", "actor", "profile", "user"):
|
||||
nested = _g(obj, key)
|
||||
if nested and (_g(nested, "handle") or _g(nested, "did")):
|
||||
return nested
|
||||
return obj
|
||||
|
||||
profile = resolve_profile(user)
|
||||
display_name = _g(profile, "displayName") or _g(profile, "display_name") or _g(profile, "handle", "")
|
||||
screen_name = _g(profile, "handle", "")
|
||||
description = _g(profile, "description", "") or ""
|
||||
followers = _g(profile, "followersCount") or _g(profile, "followers_count") or 0
|
||||
following = _g(profile, "followsCount") or _g(profile, "follows_count") or 0
|
||||
posts = _g(profile, "postsCount") or _g(profile, "posts_count") or 0
|
||||
created_at = _g(profile, "createdAt") or _g(profile, "created_at")
|
||||
created = ""
|
||||
if created_at:
|
||||
created = process_date(created_at, relative_times, offset_hours)
|
||||
|
||||
available_data = dict(
|
||||
display_name=display_name,
|
||||
screen_name=screen_name,
|
||||
description=description,
|
||||
followers=followers,
|
||||
following=following,
|
||||
favorites="",
|
||||
posts=posts,
|
||||
created_at=created,
|
||||
)
|
||||
return Template(_(template)).safe_substitute(**available_data)
|
||||
|
||||
|
||||
def render_notification(notification, template, post_template, settings, relative_times=False, offset_hours=0):
|
||||
author = _g(notification, "author") or {}
|
||||
display_name = _g(author, "displayName") or _g(author, "display_name") or _g(author, "handle", "")
|
||||
screen_name = _g(author, "handle", "")
|
||||
reason = _g(notification, "reason", "unknown")
|
||||
record = _g(notification, "record") or {}
|
||||
|
||||
# Get post text - try multiple locations depending on notification type
|
||||
post_text = _g(record, "text", "") or ""
|
||||
|
||||
# For likes and reposts: try to get the subject post text
|
||||
if not post_text and reason in ("like", "repost"):
|
||||
# First check for hydrated subject text (added by NotificationBuffer)
|
||||
post_text = _g(notification, "_subject_text", "") or ""
|
||||
|
||||
# Check if there's a reasonSubject with embedded post data
|
||||
if not post_text:
|
||||
reason_subject = _g(notification, "reasonSubject") or _g(notification, "reason_subject")
|
||||
if reason_subject:
|
||||
subject_record = _g(reason_subject, "record", {})
|
||||
post_text = _g(subject_record, "text", "") or ""
|
||||
|
||||
# Check subject in record
|
||||
if not post_text:
|
||||
subject = _g(record, "subject", {})
|
||||
post_text = _g(subject, "text", "") or ""
|
||||
|
||||
# Format: action text without username (username is already in display_name for template)
|
||||
if reason == "like":
|
||||
text = _("has added to favorites: {status}").format(status=post_text) if post_text else _("has added to favorites")
|
||||
elif reason == "repost":
|
||||
text = _("has reposted: {status}").format(status=post_text) if post_text else _("has reposted")
|
||||
elif reason == "follow":
|
||||
text = _("has followed you.")
|
||||
elif reason == "mention":
|
||||
text = _("has mentioned you: {status}").format(status=post_text) if post_text else _("has mentioned you")
|
||||
elif reason == "reply":
|
||||
text = _("has replied: {status}").format(status=post_text) if post_text else _("has replied")
|
||||
elif reason == "quote":
|
||||
text = _("has quoted your post: {status}").format(status=post_text) if post_text else _("has quoted your post")
|
||||
else:
|
||||
text = reason
|
||||
|
||||
indexed_at = _g(notification, "indexedAt") or _g(notification, "indexed_at")
|
||||
date = process_date(indexed_at, relative_times, offset_hours) if indexed_at else ""
|
||||
|
||||
available_data = dict(
|
||||
display_name=display_name,
|
||||
screen_name=screen_name,
|
||||
text=text,
|
||||
date=date,
|
||||
)
|
||||
return Template(_(template)).safe_substitute(**available_data)
|
||||
@@ -0,0 +1,444 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Utility functions for Bluesky session.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
|
||||
log = logging.getLogger("sessions.blueski.utils")
|
||||
|
||||
url_re = re.compile(r'https?://[^\s<>\[\]()"\',]+[^\s<>\[\]()"\',.:;!?]')
|
||||
|
||||
|
||||
def g(obj, key, default=None):
|
||||
"""Helper to get attribute from dict or object."""
|
||||
if isinstance(obj, dict):
|
||||
return obj.get(key, default)
|
||||
return getattr(obj, key, default)
|
||||
|
||||
|
||||
def is_audio_or_video(post):
|
||||
"""
|
||||
Check if post contains audio or video content.
|
||||
|
||||
Args:
|
||||
post: Bluesky post object (FeedViewPost or PostView)
|
||||
|
||||
Returns:
|
||||
bool: True if post has audio/video media
|
||||
"""
|
||||
actual_post = g(post, "post", post)
|
||||
embed = g(actual_post, "embed", None)
|
||||
if not embed:
|
||||
return False
|
||||
|
||||
etype = g(embed, "$type") or g(embed, "py_type") or ""
|
||||
|
||||
# Check for video embed
|
||||
if "video" in etype.lower():
|
||||
return True
|
||||
|
||||
# Check for external link that might be video (YouTube, etc.)
|
||||
if "external" in etype.lower():
|
||||
ext = g(embed, "external", {})
|
||||
uri = g(ext, "uri", "")
|
||||
video_hosts = ["youtube.com", "youtu.be", "vimeo.com", "twitch.tv", "dailymotion.com"]
|
||||
for host in video_hosts:
|
||||
if host in uri.lower():
|
||||
return True
|
||||
|
||||
# Check in recordWithMedia wrapper
|
||||
if "recordwithmedia" in etype.lower():
|
||||
media = g(embed, "media", {})
|
||||
mtype = g(media, "$type") or g(media, "py_type") or ""
|
||||
if "video" in mtype.lower():
|
||||
return True
|
||||
if "external" in mtype.lower():
|
||||
ext = g(media, "external", {})
|
||||
uri = g(ext, "uri", "")
|
||||
video_hosts = ["youtube.com", "youtu.be", "vimeo.com", "twitch.tv", "dailymotion.com"]
|
||||
for host in video_hosts:
|
||||
if host in uri.lower():
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _extract_images_from_embed(embed):
|
||||
"""Extract image URLs from an embed object."""
|
||||
images = []
|
||||
if not embed:
|
||||
return images
|
||||
|
||||
etype = g(embed, "$type") or g(embed, "py_type") or ""
|
||||
|
||||
def extract_images(img_list):
|
||||
result = []
|
||||
for img in (img_list or []):
|
||||
url = None
|
||||
# Try all possible URL field names
|
||||
for key in ["fullsize", "thumb", "url", "uri", "src"]:
|
||||
val = g(img, key)
|
||||
if val and isinstance(val, str) and val.startswith("http"):
|
||||
url = val
|
||||
break
|
||||
# Also check for nested 'image' object
|
||||
if not url:
|
||||
image_obj = g(img, "image", {})
|
||||
if image_obj:
|
||||
for key in ["ref", "$link", "url", "uri"]:
|
||||
val = g(image_obj, key)
|
||||
if val:
|
||||
url = val
|
||||
break
|
||||
if url:
|
||||
result.append({
|
||||
"url": url,
|
||||
"alt": g(img, "alt", "") or ""
|
||||
})
|
||||
return result
|
||||
|
||||
# Direct images embed (app.bsky.embed.images or app.bsky.embed.images#view)
|
||||
if "images" in etype.lower():
|
||||
images.extend(extract_images(g(embed, "images", [])))
|
||||
|
||||
# Check in recordWithMedia wrapper
|
||||
if "recordwithmedia" in etype.lower():
|
||||
media = g(embed, "media", {})
|
||||
mtype = g(media, "$type") or g(media, "py_type") or ""
|
||||
if "images" in mtype.lower():
|
||||
images.extend(extract_images(g(media, "images", [])))
|
||||
|
||||
return images
|
||||
|
||||
|
||||
def is_image(post):
|
||||
"""
|
||||
Check if post contains image content.
|
||||
|
||||
Args:
|
||||
post: Bluesky post object (FeedViewPost or PostView)
|
||||
|
||||
Returns:
|
||||
bool: True if post has image media
|
||||
"""
|
||||
actual_post = g(post, "post", post)
|
||||
embed = g(actual_post, "embed", None)
|
||||
if not embed:
|
||||
return False
|
||||
|
||||
etype = g(embed, "$type") or g(embed, "py_type") or ""
|
||||
|
||||
# Direct images embed
|
||||
if "images" in etype.lower():
|
||||
images = g(embed, "images", [])
|
||||
if images and len(images) > 0:
|
||||
return True
|
||||
|
||||
# Check in recordWithMedia wrapper
|
||||
if "recordwithmedia" in etype.lower():
|
||||
media = g(embed, "media", {})
|
||||
mtype = g(media, "$type") or g(media, "py_type") or ""
|
||||
if "images" in mtype.lower():
|
||||
images = g(media, "images", [])
|
||||
if images and len(images) > 0:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def get_image_urls(post):
|
||||
"""
|
||||
Get URLs for image attachments from post for OCR.
|
||||
|
||||
Args:
|
||||
post: Bluesky post object
|
||||
|
||||
Returns:
|
||||
list: List of dicts with 'url' and 'alt' keys
|
||||
"""
|
||||
actual_post = g(post, "post", post)
|
||||
embed = g(actual_post, "embed", None)
|
||||
return _extract_images_from_embed(embed)
|
||||
|
||||
|
||||
def get_media_urls(post):
|
||||
"""
|
||||
Get URLs for media attachments (video/audio) from post.
|
||||
|
||||
Args:
|
||||
post: Bluesky post object
|
||||
|
||||
Returns:
|
||||
list: List of media URLs
|
||||
"""
|
||||
urls = []
|
||||
actual_post = g(post, "post", post)
|
||||
embed = g(actual_post, "embed", None)
|
||||
if not embed:
|
||||
return urls
|
||||
|
||||
etype = g(embed, "$type") or g(embed, "py_type") or ""
|
||||
|
||||
def extract_video_urls(video_embed):
|
||||
"""Extract URLs from a video embed object."""
|
||||
result = []
|
||||
# Playlist URL (HLS stream)
|
||||
playlist = g(video_embed, "playlist", None)
|
||||
if playlist:
|
||||
result.append(playlist)
|
||||
# Alternative URL fields
|
||||
for key in ["url", "uri"]:
|
||||
val = g(video_embed, key)
|
||||
if val and val not in result:
|
||||
result.append(val)
|
||||
return result
|
||||
|
||||
# Direct video embed (app.bsky.embed.video#view)
|
||||
if "video" in etype.lower():
|
||||
urls.extend(extract_video_urls(embed))
|
||||
|
||||
# Check in recordWithMedia wrapper
|
||||
if "recordWithMedia" in etype or "record_with_media" in etype.lower():
|
||||
media = g(embed, "media", {})
|
||||
mtype = g(media, "$type") or g(media, "py_type") or ""
|
||||
if "video" in mtype.lower():
|
||||
urls.extend(extract_video_urls(media))
|
||||
# Also check for external in media
|
||||
if "external" in mtype.lower():
|
||||
ext = g(media, "external", {})
|
||||
uri = g(ext, "uri", "")
|
||||
if uri and uri not in urls:
|
||||
urls.append(uri)
|
||||
|
||||
# External links (YouTube, etc.)
|
||||
if "external" in etype.lower():
|
||||
ext = g(embed, "external", {})
|
||||
uri = g(ext, "uri", "")
|
||||
if uri and uri not in urls:
|
||||
urls.append(uri)
|
||||
|
||||
return urls
|
||||
|
||||
|
||||
def find_urls(post):
|
||||
"""
|
||||
Find all URLs in post content.
|
||||
|
||||
Args:
|
||||
post: Bluesky post object
|
||||
|
||||
Returns:
|
||||
list: List of URLs found
|
||||
"""
|
||||
urls = []
|
||||
actual_post = g(post, "post", post)
|
||||
record = g(actual_post, "record", {})
|
||||
|
||||
# Check facets for link annotations
|
||||
facets = g(record, "facets", []) or []
|
||||
for facet in facets:
|
||||
features = g(facet, "features", []) or []
|
||||
for feature in features:
|
||||
ftype = g(feature, "$type") or g(feature, "py_type")
|
||||
if ftype and "link" in ftype.lower():
|
||||
uri = g(feature, "uri", "")
|
||||
if uri and uri not in urls:
|
||||
urls.append(uri)
|
||||
|
||||
# Check embed for external links
|
||||
embed = g(actual_post, "embed", None)
|
||||
if embed:
|
||||
etype = g(embed, "$type") or g(embed, "py_type")
|
||||
if etype and "external" in etype:
|
||||
ext = g(embed, "external", {})
|
||||
uri = g(ext, "uri", "")
|
||||
if uri and uri not in urls:
|
||||
urls.append(uri)
|
||||
|
||||
# Also search plain text for URLs using regex (fallback)
|
||||
text = g(record, "text", "")
|
||||
if text:
|
||||
text_urls = url_re.findall(text)
|
||||
for u in text_urls:
|
||||
if u not in urls:
|
||||
urls.append(u)
|
||||
|
||||
# Include URLs from quoted post, if present.
|
||||
quote_info = extract_quoted_post_info(post)
|
||||
if quote_info and quote_info.get("kind") == "post":
|
||||
for uri in quote_info.get("urls", []):
|
||||
if uri and uri not in urls:
|
||||
urls.append(uri)
|
||||
|
||||
return urls
|
||||
|
||||
|
||||
def find_item(item, items_list):
|
||||
"""
|
||||
Find item index in list by URI.
|
||||
|
||||
Args:
|
||||
item: Item to find
|
||||
items_list: List to search
|
||||
|
||||
Returns:
|
||||
int or None: Index if found, None otherwise
|
||||
"""
|
||||
item_uri = g(item, "uri") or g(g(item, "post"), "uri")
|
||||
if not item_uri:
|
||||
return None
|
||||
|
||||
for i, existing in enumerate(items_list):
|
||||
existing_uri = g(existing, "uri") or g(g(existing, "post"), "uri")
|
||||
if existing_uri == item_uri:
|
||||
return i
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_quoted_record_from_embed(embed):
|
||||
"""Resolve quoted record payload from a Bluesky embed structure."""
|
||||
if not embed:
|
||||
return None
|
||||
|
||||
etype = (g(embed, "$type") or g(embed, "py_type") or "").lower()
|
||||
|
||||
candidate = None
|
||||
if "recordwithmedia" in etype:
|
||||
record_view = g(embed, "record")
|
||||
candidate = g(record_view, "record") or record_view
|
||||
elif "record" in etype:
|
||||
candidate = g(embed, "record") or embed
|
||||
else:
|
||||
record_view = g(embed, "record")
|
||||
if record_view is not None:
|
||||
candidate = g(record_view, "record") or record_view
|
||||
|
||||
if not candidate:
|
||||
return None
|
||||
|
||||
# Unwrap one extra layer if still wrapped in a record-view container.
|
||||
nested = g(candidate, "record")
|
||||
nested_type = (g(nested, "$type") or g(nested, "py_type") or "").lower() if nested else ""
|
||||
if nested and ("view" in nested_type or "record" in nested_type):
|
||||
return nested
|
||||
|
||||
return candidate
|
||||
|
||||
|
||||
def extract_reply_to_handle(post):
|
||||
"""
|
||||
Best-effort extraction of the replied-to handle for a Bluesky post.
|
||||
|
||||
Returns:
|
||||
str | None: Handle (without @) when available.
|
||||
"""
|
||||
actual_post = g(post, "post", post)
|
||||
|
||||
# Fast path: pre-hydrated by buffers/session.
|
||||
cached = g(post, "_reply_to_handle", None) or g(actual_post, "_reply_to_handle", None)
|
||||
if cached:
|
||||
return cached
|
||||
|
||||
# Feed views frequently include hydrated reply context.
|
||||
reply_view = g(post, "reply", None) or g(actual_post, "reply", None)
|
||||
if reply_view:
|
||||
parent = g(reply_view, "parent", None) or g(reply_view, "post", None) or reply_view
|
||||
parent_post = g(parent, "post", None) or parent
|
||||
parent_author = g(parent_post, "author", None) or g(parent, "author", None)
|
||||
handle = g(parent_author, "handle", None)
|
||||
if handle:
|
||||
return handle
|
||||
|
||||
# Some payloads include parent author directly under record.reply.parent.
|
||||
record = g(actual_post, "record", {}) or {}
|
||||
record_reply = g(record, "reply", None)
|
||||
if record_reply:
|
||||
parent = g(record_reply, "parent", None) or record_reply
|
||||
parent_post = g(parent, "post", None) or parent
|
||||
parent_author = g(parent_post, "author", None) or g(parent, "author", None)
|
||||
handle = g(parent_author, "handle", None)
|
||||
if handle:
|
||||
return handle
|
||||
|
||||
# When only record.reply is available, we generally only have strong refs.
|
||||
# No handle can be resolved here without extra API calls.
|
||||
return None
|
||||
|
||||
|
||||
def extract_quoted_post_info(post):
|
||||
"""
|
||||
Extract quoted content metadata from a Bluesky post.
|
||||
|
||||
Returns:
|
||||
dict | None: one of:
|
||||
- {"kind": "not_found"}
|
||||
- {"kind": "blocked"}
|
||||
- {"kind": "feed", "feed_name": "..."}
|
||||
- {"kind": "post", "handle": "...", "text": "...", "urls": ["..."]}
|
||||
"""
|
||||
actual_post = g(post, "post", post)
|
||||
record = g(actual_post, "record", {}) or {}
|
||||
embed = g(actual_post, "embed", None) or g(record, "embed", None)
|
||||
quote_rec = _resolve_quoted_record_from_embed(embed)
|
||||
if not quote_rec:
|
||||
return None
|
||||
|
||||
qtype = (g(quote_rec, "$type") or g(quote_rec, "py_type") or "").lower()
|
||||
if "viewnotfound" in qtype:
|
||||
return {"kind": "not_found"}
|
||||
if "viewblocked" in qtype:
|
||||
return {"kind": "blocked"}
|
||||
if "generatorview" in qtype:
|
||||
return {"kind": "feed", "feed_name": g(quote_rec, "displayName", "Feed")}
|
||||
|
||||
q_author = g(quote_rec, "author", {}) or {}
|
||||
q_handle = g(q_author, "handle", "unknown") or "unknown"
|
||||
|
||||
q_value = g(quote_rec, "value") or g(quote_rec, "record") or {}
|
||||
q_text = g(q_value, "text", "") or g(quote_rec, "text", "")
|
||||
if not q_text:
|
||||
nested_value = g(q_value, "value") or {}
|
||||
q_text = g(nested_value, "text", "")
|
||||
|
||||
q_urls = []
|
||||
|
||||
q_facets = g(q_value, "facets", []) or []
|
||||
for facet in q_facets:
|
||||
features = g(facet, "features", []) or []
|
||||
for feature in features:
|
||||
ftype = (g(feature, "$type") or g(feature, "py_type") or "").lower()
|
||||
if "link" in ftype:
|
||||
uri = g(feature, "uri", "")
|
||||
if uri and uri not in q_urls:
|
||||
q_urls.append(uri)
|
||||
|
||||
q_embed = g(quote_rec, "embed", None) or g(q_value, "embed", None)
|
||||
if q_embed:
|
||||
q_etype = (g(q_embed, "$type") or g(q_embed, "py_type") or "").lower()
|
||||
if "external" in q_etype:
|
||||
ext = g(q_embed, "external", {})
|
||||
uri = g(ext, "uri", "")
|
||||
if uri and uri not in q_urls:
|
||||
q_urls.append(uri)
|
||||
if "recordwithmedia" in q_etype:
|
||||
media = g(q_embed, "media", {})
|
||||
mtype = (g(media, "$type") or g(media, "py_type") or "").lower()
|
||||
if "external" in mtype:
|
||||
ext = g(media, "external", {})
|
||||
uri = g(ext, "uri", "")
|
||||
if uri and uri not in q_urls:
|
||||
q_urls.append(uri)
|
||||
|
||||
for uri in url_re.findall(q_text or ""):
|
||||
if uri not in q_urls:
|
||||
q_urls.append(uri)
|
||||
|
||||
return {
|
||||
"kind": "post",
|
||||
"handle": q_handle,
|
||||
"text": q_text or "",
|
||||
"urls": q_urls,
|
||||
}
|
||||
@@ -510,4 +510,4 @@ class Session(base.baseSession):
|
||||
# Now, add notification to its buffer.
|
||||
num = self.order_buffer("notifications", [notification])
|
||||
if num > 0:
|
||||
pub.sendMessage("mastodon.new_item", session_name=self.get_name(), item=notification, _buffers=["notifications"])
|
||||
pub.sendMessage("mastodon.new_item", session_name=self.get_name(), item=notification, _buffers=["notifications"])
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import builtins
|
||||
import unittest
|
||||
|
||||
from sessions.blueski import compose, templates, utils
|
||||
|
||||
|
||||
class TestBlueskyQuotedPosts(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
if not hasattr(builtins, "_"):
|
||||
builtins._ = lambda s: s
|
||||
|
||||
def _build_quoted_post(self):
|
||||
return {
|
||||
"post": {
|
||||
"author": {"handle": "alice.bsky.social", "displayName": "Alice"},
|
||||
"record": {"text": "Main post text"},
|
||||
"embed": {
|
||||
"$type": "app.bsky.embed.recordWithMedia#view",
|
||||
"record": {
|
||||
"$type": "app.bsky.embed.record#view",
|
||||
"record": {
|
||||
"$type": "app.bsky.embed.record#viewRecord",
|
||||
"author": {"handle": "bob.bsky.social"},
|
||||
"value": {"text": "Quoted post text"},
|
||||
},
|
||||
},
|
||||
"media": {
|
||||
"$type": "app.bsky.embed.images#view",
|
||||
"images": [],
|
||||
},
|
||||
},
|
||||
"indexedAt": "2026-02-15T10:00:00Z",
|
||||
}
|
||||
}
|
||||
|
||||
def _with_reply_context(self, item, reply_to_handle="carol.bsky.social"):
|
||||
if isinstance(item, dict):
|
||||
item["reply"] = {
|
||||
"parent": {
|
||||
"author": {
|
||||
"handle": reply_to_handle,
|
||||
}
|
||||
}
|
||||
}
|
||||
return item
|
||||
|
||||
def test_extract_quoted_post_info_with_text(self):
|
||||
item = self._build_quoted_post()
|
||||
info = utils.extract_quoted_post_info(item)
|
||||
self.assertIsNotNone(info)
|
||||
self.assertEqual(info["kind"], "post")
|
||||
self.assertEqual(info["handle"], "bob.bsky.social")
|
||||
self.assertEqual(info["text"], "Quoted post text")
|
||||
|
||||
def test_compose_post_includes_quoted_text(self):
|
||||
item = self._build_quoted_post()
|
||||
result = compose.compose_post(
|
||||
item,
|
||||
db={},
|
||||
settings={"general": {}},
|
||||
relative_times=True,
|
||||
show_screen_names=False,
|
||||
safe=True,
|
||||
)
|
||||
self.assertIn("Quoting @bob.bsky.social: Quoted post text", result[1])
|
||||
|
||||
def test_template_render_post_includes_quoted_text(self):
|
||||
item = self._build_quoted_post()
|
||||
rendered = templates.render_post(
|
||||
item,
|
||||
template="$display_name, $safe_text $date.",
|
||||
settings={"general": {}},
|
||||
relative_times=True,
|
||||
offset_hours=0,
|
||||
)
|
||||
self.assertIn("Quoting @bob.bsky.social: Quoted post text", rendered)
|
||||
|
||||
def test_extract_quoted_post_info_includes_urls(self):
|
||||
item = self._build_quoted_post()
|
||||
quoted = item["post"]["embed"]["record"]["record"]
|
||||
quoted["value"]["facets"] = [
|
||||
{
|
||||
"features": [
|
||||
{
|
||||
"$type": "app.bsky.richtext.facet#link",
|
||||
"uri": "https://example.com/full-link",
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
info = utils.extract_quoted_post_info(item)
|
||||
self.assertIn("https://example.com/full-link", info["urls"])
|
||||
|
||||
def test_find_urls_includes_quoted_urls(self):
|
||||
item = self._build_quoted_post()
|
||||
quoted = item["post"]["embed"]["record"]["record"]
|
||||
quoted["value"]["facets"] = [
|
||||
{
|
||||
"features": [
|
||||
{
|
||||
"$type": "app.bsky.richtext.facet#link",
|
||||
"uri": "https://example.com/quoted-only",
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
urls = utils.find_urls(item)
|
||||
self.assertIn("https://example.com/quoted-only", urls)
|
||||
|
||||
def test_compose_post_appends_full_quoted_url(self):
|
||||
item = self._build_quoted_post()
|
||||
quoted = item["post"]["embed"]["record"]["record"]
|
||||
quoted["value"]["text"] = "Mira example.com/..."
|
||||
quoted["value"]["facets"] = [
|
||||
{
|
||||
"features": [
|
||||
{
|
||||
"$type": "app.bsky.richtext.facet#link",
|
||||
"uri": "https://example.com/full-target",
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
result = compose.compose_post(
|
||||
item,
|
||||
db={},
|
||||
settings={"general": {}},
|
||||
relative_times=True,
|
||||
show_screen_names=False,
|
||||
safe=True,
|
||||
)
|
||||
self.assertIn("[https://example.com/full-target]", result[1])
|
||||
|
||||
def test_extract_reply_to_handle_from_record_parent_author(self):
|
||||
item = {
|
||||
"post": {
|
||||
"record": {
|
||||
"text": "Reply text",
|
||||
"reply": {
|
||||
"parent": {
|
||||
"uri": "at://did:plc:parent/app.bsky.feed.post/abc",
|
||||
"author": {"handle": "parent.user"},
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
handle = utils.extract_reply_to_handle(item)
|
||||
self.assertEqual(handle, "parent.user")
|
||||
|
||||
def test_template_render_post_reply_fallback_without_reply_to_variable(self):
|
||||
item = {
|
||||
"post": {
|
||||
"author": {"handle": "alice.bsky.social", "displayName": "Alice"},
|
||||
"record": {
|
||||
"text": "Reply body",
|
||||
"reply": {
|
||||
"parent": {
|
||||
"uri": "at://did:plc:parent/app.bsky.feed.post/abc",
|
||||
"author": {"handle": "parent.user"},
|
||||
}
|
||||
},
|
||||
},
|
||||
"indexedAt": "2026-02-15T10:00:00Z",
|
||||
}
|
||||
}
|
||||
|
||||
rendered = templates.render_post(
|
||||
item,
|
||||
template="$display_name, $safe_text $date.",
|
||||
settings={"general": {}},
|
||||
relative_times=True,
|
||||
offset_hours=0,
|
||||
)
|
||||
self.assertIn("Replying to @parent.user.", rendered)
|
||||
|
||||
def test_extract_reply_to_handle(self):
|
||||
item = self._with_reply_context(self._build_quoted_post())
|
||||
handle = utils.extract_reply_to_handle(item)
|
||||
self.assertEqual(handle, "carol.bsky.social")
|
||||
|
||||
def test_compose_post_includes_reply_context(self):
|
||||
item = self._with_reply_context(self._build_quoted_post())
|
||||
result = compose.compose_post(
|
||||
item,
|
||||
db={},
|
||||
settings={"general": {}},
|
||||
relative_times=True,
|
||||
show_screen_names=False,
|
||||
safe=True,
|
||||
)
|
||||
self.assertIn("Replying to @carol.bsky.social:", result[1])
|
||||
|
||||
def test_template_render_post_exposes_reply_to_variable(self):
|
||||
item = self._with_reply_context(self._build_quoted_post())
|
||||
rendered = templates.render_post(
|
||||
item,
|
||||
template="$reply_to$text",
|
||||
settings={"general": {}},
|
||||
relative_times=True,
|
||||
offset_hours=0,
|
||||
)
|
||||
self.assertIn("Replying to @carol.bsky.social.", rendered)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,362 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import sys
|
||||
import unittest
|
||||
from unittest.mock import patch, AsyncMock, MagicMock, PropertyMock
|
||||
|
||||
# Assuming paths are set up correctly for test environment to find these
|
||||
from sessions.blueski.session import Session as BlueskiSession
|
||||
from sessions.session_exceptions import SessionLoginError, SessionError
|
||||
from approve.notifications import NotificationError # Assuming this is the correct import path
|
||||
from atproto.xrpc_client.models.common import XrpcError
|
||||
from atproto.xrpc_client import models as atp_models # For ATProto models
|
||||
from atproto.xrpc_client.models import ids # For lexicon IDs
|
||||
|
||||
# Mock wx for headless testing
|
||||
class MockWxDialog:
|
||||
def __init__(self, parent, message, caption, value="", style=0):
|
||||
self.message = message
|
||||
self.caption = caption
|
||||
self.value = value
|
||||
self.return_code = mock_wx.ID_CANCEL # Default to cancel, specific tests can change this
|
||||
|
||||
def ShowModal(self):
|
||||
return self.return_code
|
||||
|
||||
def GetValue(self):
|
||||
return self.value
|
||||
|
||||
def Destroy(self):
|
||||
pass
|
||||
|
||||
class MockWxMessageBox(MockWxDialog):
|
||||
pass
|
||||
|
||||
# Need to mock wx before it's imported by other modules if they do it at import time.
|
||||
# Patching directly where used in session.py is generally safer.
|
||||
mock_wx = MagicMock()
|
||||
mock_wx.TextEntryDialog = MockWxDialog
|
||||
mock_wx.PasswordEntryDialog = MockWxDialog
|
||||
mock_wx.MessageBox = MockWxMessageBox
|
||||
mock_wx.ID_OK = 1
|
||||
mock_wx.ID_CANCEL = 2
|
||||
mock_wx.ICON_ERROR = 16
|
||||
mock_wx.ICON_INFORMATION = 64
|
||||
mock_wx.OK = 4
|
||||
mock_wx.YES_NO = 1 # Example, actual value might differ but not critical for test logic
|
||||
mock_wx.YES = 1 # Example
|
||||
mock_wx.ICON_QUESTION = 32 # Example
|
||||
|
||||
# Mock config objects
|
||||
# This structure tries to mimic how config is accessed in session.py
|
||||
# e.g., config.sessions.blueski[user_id].handle
|
||||
class MockConfigNode:
|
||||
def __init__(self, initial_value=None):
|
||||
self._value = initial_value
|
||||
self.get = MagicMock(return_value=self._value)
|
||||
self.set = AsyncMock() # .set() is async
|
||||
|
||||
class MockUserSessionConfig:
|
||||
def __init__(self):
|
||||
self.handle = MockConfigNode("")
|
||||
self.app_password = MockConfigNode("")
|
||||
self.did = MockConfigNode("")
|
||||
# Add other config values if session.py uses them for blueski
|
||||
|
||||
class MockBlueskiConfig:
|
||||
def __init__(self):
|
||||
self._user_configs = {"test_user": MockUserSessionConfig()}
|
||||
def __getitem__(self, key):
|
||||
return self._user_configs.get(key, MagicMock(return_value=MockUserSessionConfig())) # Return a mock if key not found
|
||||
|
||||
class MockSessionsConfig:
|
||||
def __init__(self):
|
||||
self.blueski = MockBlueskiConfig()
|
||||
|
||||
mock_config_global = MagicMock()
|
||||
mock_config_global.sessions = MockSessionsConfig()
|
||||
|
||||
|
||||
class TestBlueskiSession(unittest.IsolatedAsyncioTestCase):
|
||||
|
||||
@patch('sessions.blueski.session.wx', mock_wx)
|
||||
@patch('sessions.blueski.session.config', mock_config_global)
|
||||
def setUp(self):
|
||||
self.mock_approval_api = MagicMock()
|
||||
|
||||
# Reset mocks for user_config part of global mock_config_global for each test
|
||||
self.mock_user_config_instance = MockUserSessionConfig()
|
||||
mock_config_global.sessions.blueski.__getitem__.return_value = self.mock_user_config_instance
|
||||
|
||||
self.session = BlueskiSession(approval_api=self.mock_approval_api, user_id="test_user", channel_id="test_channel")
|
||||
|
||||
self.session.db = {}
|
||||
self.session.save_db = AsyncMock()
|
||||
self.session.notify_session_ready = AsyncMock()
|
||||
self.session.send_text_notification = MagicMock()
|
||||
|
||||
# Mock the util property to return a MagicMock for BlueskiUtils
|
||||
self.mock_util_instance = AsyncMock() # Make it an AsyncMock if its methods are async
|
||||
self.mock_util_instance._own_did = None # These are set directly by session.login
|
||||
self.mock_util_instance._own_handle = None
|
||||
# Add any methods from util that are directly called by session methods being tested
|
||||
# e.g., self.mock_util_instance.get_own_did = MagicMock(return_value="did:plc:test")
|
||||
self.session._util = self.mock_util_instance
|
||||
self.session.util # Call property to ensure _util is set if it's lazy loaded
|
||||
|
||||
def test_session_initialization(self):
|
||||
self.assertIsInstance(self.session, BlueskiSession)
|
||||
self.assertEqual(self.session.KIND, "blueski")
|
||||
self.assertIsNone(self.session.client)
|
||||
self.assertEqual(self.session.user_id, "test_user")
|
||||
|
||||
@patch('sessions.blueski.session.AsyncClient')
|
||||
async def test_login_successful(self, MockAsyncClient):
|
||||
mock_client_instance = MockAsyncClient.return_value
|
||||
# Use actual ATProto models for spec if possible for better type checking in mocks
|
||||
mock_profile = MagicMock(spec=atp_models.ComAtprotoServerDefs.Session)
|
||||
mock_profile.access_jwt = "fake_access_jwt"
|
||||
mock_profile.refresh_jwt = "fake_refresh_jwt"
|
||||
mock_profile.did = "did:plc:testdid"
|
||||
mock_profile.handle = "testhandle.bsky.social"
|
||||
mock_client_instance.login = AsyncMock(return_value=mock_profile)
|
||||
|
||||
self.session.config_get = MagicMock(return_value=None) # Simulate no pre-existing config
|
||||
|
||||
result = await self.session.login("testhandle.bsky.social", "test_password")
|
||||
|
||||
self.assertTrue(result)
|
||||
self.assertIsNotNone(self.session.client)
|
||||
mock_client_instance.login.assert_called_once_with("testhandle.bsky.social", "test_password")
|
||||
|
||||
self.assertEqual(self.session.db.get("access_jwt"), "fake_access_jwt")
|
||||
self.assertEqual(self.session.db.get("did"), "did:plc:testdid")
|
||||
self.assertEqual(self.session.db.get("handle"), "testhandle.bsky.social")
|
||||
self.session.save_db.assert_called_once()
|
||||
|
||||
self.mock_user_config_instance.handle.set.assert_called_once_with("testhandle.bsky.social")
|
||||
self.mock_user_config_instance.app_password.set.assert_called_once_with("test_password")
|
||||
self.mock_user_config_instance.did.set.assert_called_once_with("did:plc:testdid")
|
||||
|
||||
self.assertEqual(self.session._util._own_did, "did:plc:testdid")
|
||||
self.assertEqual(self.session._util._own_handle, "testhandle.bsky.social")
|
||||
|
||||
self.session.notify_session_ready.assert_called_once()
|
||||
|
||||
@patch('sessions.blueski.session.AsyncClient')
|
||||
async def test_login_failure_xrpc(self, MockAsyncClient):
|
||||
mock_client_instance = MockAsyncClient.return_value
|
||||
mock_client_instance.login = AsyncMock(side_effect=XrpcError(error="AuthenticationFailed", message="Invalid credentials"))
|
||||
self.session.config_get = MagicMock(return_value=None)
|
||||
|
||||
with self.assertRaises(NotificationError) as ctx:
|
||||
await self.session.login("testhandle.bsky.social", "wrong_password")
|
||||
|
||||
self.assertTrue("Invalid handle or app password." in str(ctx.exception) or "Invalid credentials" in str(ctx.exception))
|
||||
self.assertIsNone(self.session.client)
|
||||
self.session.notify_session_ready.assert_not_called()
|
||||
|
||||
@patch('sessions.blueski.session.wx', new=mock_wx)
|
||||
@patch.object(BlueskiSession, 'login', new_callable=AsyncMock)
|
||||
async def test_authorise_successful(self, mock_login_method):
|
||||
mock_login_method.return_value = True
|
||||
|
||||
mock_wx.TextEntryDialog.return_value.GetValue = MagicMock(return_value="test_handle")
|
||||
mock_wx.TextEntryDialog.return_value.ShowModal = MagicMock(return_value=mock_wx.ID_OK)
|
||||
mock_wx.PasswordEntryDialog.return_value.GetValue = MagicMock(return_value="password_ok")
|
||||
mock_wx.PasswordEntryDialog.return_value.ShowModal = MagicMock(return_value=mock_wx.ID_OK)
|
||||
|
||||
self.session.config_get = MagicMock(return_value="prefill_handle") # For pre-filling handle dialog
|
||||
|
||||
result = await self.session.authorise()
|
||||
|
||||
self.assertTrue(result)
|
||||
mock_login_method.assert_called_once_with("test_handle", "password_ok")
|
||||
# Further check if wx.MessageBox was called with success
|
||||
# This requires more complex mocking or inspection of calls to mock_wx.MessageBox
|
||||
|
||||
@patch('sessions.blueski.session.wx', new=mock_wx)
|
||||
@patch.object(BlueskiSession, 'login', new_callable=AsyncMock)
|
||||
async def test_authorise_login_fails_with_notification_error(self, mock_login_method):
|
||||
mock_login_method.side_effect = NotificationError("Specific login failure from mock.")
|
||||
|
||||
mock_wx.TextEntryDialog.return_value.GetValue = MagicMock(return_value="test_handle")
|
||||
mock_wx.TextEntryDialog.return_value.ShowModal = MagicMock(return_value=mock_wx.ID_OK)
|
||||
mock_wx.PasswordEntryDialog.return_value.GetValue = MagicMock(return_value="any_password")
|
||||
mock_wx.PasswordEntryDialog.return_value.ShowModal = MagicMock(return_value=mock_wx.ID_OK)
|
||||
|
||||
self.session.config_get = MagicMock(return_value="")
|
||||
|
||||
result = await self.session.authorise()
|
||||
self.assertFalse(result)
|
||||
mock_login_method.assert_called_once()
|
||||
|
||||
|
||||
# --- Test Sending Posts ---
|
||||
async def test_send_simple_post_successful(self):
|
||||
self.session.is_ready = MagicMock(return_value=True) # Assume session is ready
|
||||
self.session.util.post_status = AsyncMock(return_value="at://mock_post_uri")
|
||||
|
||||
post_uri = await self.session.send_message("Test text post")
|
||||
|
||||
self.assertEqual(post_uri, "at://mock_post_uri")
|
||||
self.session.util.post_status.assert_called_once_with(
|
||||
text="Test text post", media_ids=None, reply_to_uri=None, quote_uri=None,
|
||||
cw_text=None, is_sensitive=False, langs=None, tags=None
|
||||
)
|
||||
|
||||
async def test_send_post_with_quote_and_lang(self):
|
||||
self.session.is_ready = MagicMock(return_value=True)
|
||||
self.session.util.post_status = AsyncMock(return_value="at://mock_post_uri_quote")
|
||||
|
||||
post_uri = await self.session.send_message(
|
||||
"Quoting another post",
|
||||
quote_uri="at://did:plc:someuser/app.bsky.feed.post/somepostid",
|
||||
langs=["en", "es"]
|
||||
)
|
||||
self.assertEqual(post_uri, "at://mock_post_uri_quote")
|
||||
self.session.util.post_status.assert_called_once_with(
|
||||
text="Quoting another post", media_ids=None, reply_to_uri=None,
|
||||
quote_uri="at://did:plc:someuser/app.bsky.feed.post/somepostid",
|
||||
cw_text=None, is_sensitive=False, langs=["en", "es"], tags=None
|
||||
)
|
||||
|
||||
@patch('sessions.blueski.session.os.path.basename', return_value="image.png") # Mock os.path.basename
|
||||
async def test_send_post_with_media(self, mock_basename):
|
||||
self.session.is_ready = MagicMock(return_value=True)
|
||||
mock_blob_info = {"blob_ref": MagicMock(spec=atp_models.ComAtprotoRepoStrongRef.Blob), "alt_text": "A test image"}
|
||||
self.session.util.upload_media = AsyncMock(return_value=mock_blob_info)
|
||||
self.session.util.post_status = AsyncMock(return_value="at://mock_post_uri_media")
|
||||
|
||||
post_uri = await self.session.send_message(
|
||||
"Post with media", files=["dummy/path/image.png"], media_alt_texts=["A test image"]
|
||||
)
|
||||
self.assertEqual(post_uri, "at://mock_post_uri_media")
|
||||
self.session.util.upload_media.assert_called_once_with("dummy/path/image.png", "image/png", alt_text="A test image")
|
||||
self.session.util.post_status.assert_called_once_with(
|
||||
text="Post with media", media_ids=[mock_blob_info], reply_to_uri=None, quote_uri=None,
|
||||
cw_text=None, is_sensitive=False, langs=None, tags=None
|
||||
)
|
||||
|
||||
async def test_send_post_util_failure(self):
|
||||
self.session.is_ready = MagicMock(return_value=True)
|
||||
self.session.util.post_status = AsyncMock(side_effect=NotificationError("Failed to post from util"))
|
||||
with self.assertRaisesRegex(NotificationError, "Failed to post from util"):
|
||||
await self.session.send_message("This will fail")
|
||||
|
||||
# --- Test Fetching Timelines ---
|
||||
def _create_mock_feed_view_post(self, uri_suffix):
|
||||
post_view = MagicMock(spec=atp_models.AppBskyFeedDefs.PostView)
|
||||
post_view.uri = f"at://did:plc:test/app.bsky.feed.post/{uri_suffix}"
|
||||
post_view.cid = f"cid_{uri_suffix}"
|
||||
author_mock = MagicMock(spec=atp_models.AppBskyActorDefs.ProfileViewBasic)
|
||||
author_mock.did = "did:plc:author"
|
||||
author_mock.handle = "author.bsky.social"
|
||||
post_view.author = author_mock
|
||||
record_mock = MagicMock(spec=atp_models.AppBskyFeedPost.Main)
|
||||
record_mock.text = f"Text of post {uri_suffix}"
|
||||
record_mock.createdAt = "2024-01-01T00:00:00Z"
|
||||
post_view.record = record_mock
|
||||
feed_view_post = MagicMock(spec=atp_models.AppBskyFeedDefs.FeedViewPost)
|
||||
feed_view_post.post = post_view
|
||||
feed_view_post.reason = None
|
||||
feed_view_post.reply = None
|
||||
return feed_view_post
|
||||
|
||||
async def test_fetch_home_timeline_successful(self):
|
||||
self.session.is_ready = MagicMock(return_value=True)
|
||||
mock_post1 = self._create_mock_feed_view_post("post1")
|
||||
mock_post2 = self._create_mock_feed_view_post("post2")
|
||||
self.session.util.get_timeline = AsyncMock(return_value=([mock_post1, mock_post2], "cursor_for_home"))
|
||||
self.session.order_buffer = AsyncMock(return_value=["uri1", "uri2"])
|
||||
|
||||
processed_uris, next_cursor = await self.session.fetch_home_timeline(limit=5, new_only=True)
|
||||
|
||||
self.session.util.get_timeline.assert_called_once_with(algorithm=None, limit=5, cursor=None)
|
||||
self.session.order_buffer.assert_called_once_with(items=[mock_post1, mock_post2], new_only=True, buffer_name="home_timeline_buffer")
|
||||
self.assertEqual(self.session.home_timeline_cursor, "cursor_for_home")
|
||||
self.assertEqual(processed_uris, ["uri1", "uri2"])
|
||||
|
||||
async def test_fetch_user_timeline_successful(self):
|
||||
self.session.is_ready = MagicMock(return_value=True)
|
||||
mock_post3 = self._create_mock_feed_view_post("post3")
|
||||
self.session.util.get_author_feed = AsyncMock(return_value=([mock_post3], "cursor_for_user"))
|
||||
self.session.order_buffer = AsyncMock(return_value=["uri3"])
|
||||
|
||||
processed_uris, next_cursor = await self.session.fetch_user_timeline(
|
||||
user_did="did:plc:targetuser", limit=10, filter_type="posts_no_replies"
|
||||
)
|
||||
self.session.util.get_author_feed.assert_called_once_with(
|
||||
actor_did="did:plc:targetuser", limit=10, cursor=None, filter="posts_no_replies"
|
||||
)
|
||||
self.session.order_buffer.assert_called_once_with(items=[mock_post3], new_only=False, buffer_name='user_timeline_did:plc:targetuser')
|
||||
self.assertEqual(next_cursor, "cursor_for_user")
|
||||
self.assertEqual(processed_uris, ["uri3"])
|
||||
|
||||
async def test_fetch_timeline_failure(self):
|
||||
self.session.is_ready = MagicMock(return_value=True)
|
||||
self.session.util.get_timeline = AsyncMock(side_effect=NotificationError("API error for timeline"))
|
||||
with self.assertRaisesRegex(NotificationError, "API error for timeline"):
|
||||
await self.session.fetch_home_timeline()
|
||||
|
||||
# --- Test Fetching Notifications ---
|
||||
def _create_mock_notification(self, reason: str, uri_suffix: str, isRead: bool = False):
|
||||
notif = MagicMock(spec=atp_models.AppBskyNotificationListNotifications.Notification)
|
||||
notif.uri = f"at://did:plc:test/app.bsky.feed.like/{uri_suffix}"
|
||||
notif.cid = f"cid_notif_{uri_suffix}"
|
||||
author_mock = MagicMock(spec=atp_models.AppBskyActorDefs.ProfileView)
|
||||
author_mock.did = f"did:plc:otheruser{uri_suffix}"
|
||||
author_mock.handle = f"other{uri_suffix}.bsky.social"
|
||||
author_mock.displayName = f"Other User {uri_suffix}"
|
||||
author_mock.avatar = "http://example.com/avatar.png"
|
||||
notif.author = author_mock
|
||||
notif.reason = reason
|
||||
notif.reasonSubject = f"at://did:plc:test/app.bsky.feed.post/mypost{uri_suffix}" if reason != "follow" else None
|
||||
if reason in ["mention", "reply", "quote"]:
|
||||
record_mock = MagicMock(spec=atp_models.AppBskyFeedPost.Main)
|
||||
record_mock.text = f"Notification related text for {reason}"
|
||||
record_mock.createdAt = "2024-01-02T00:00:00Z"
|
||||
notif.record = record_mock
|
||||
else:
|
||||
notif.record = MagicMock()
|
||||
notif.isRead = isRead
|
||||
notif.indexedAt = "2024-01-02T00:00:00Z"
|
||||
return notif
|
||||
|
||||
async def test_fetch_notifications_successful_and_handler_dispatch(self):
|
||||
self.session.is_ready = MagicMock(return_value=True)
|
||||
self.session.util = AsyncMock()
|
||||
mock_like_notif = self._create_mock_notification("like", "like1", isRead=False)
|
||||
mock_mention_notif = self._create_mock_notification("mention", "mention1", isRead=False)
|
||||
self.session.util.get_notifications = AsyncMock(return_value=([mock_like_notif, mock_mention_notif], "next_notif_cursor"))
|
||||
|
||||
self.session._handle_like_notification = AsyncMock()
|
||||
self.session._handle_mention_notification = AsyncMock()
|
||||
self.session._handle_repost_notification = AsyncMock()
|
||||
self.session._handle_follow_notification = AsyncMock()
|
||||
self.session._handle_reply_notification = AsyncMock()
|
||||
self.session._handle_quote_notification = AsyncMock()
|
||||
|
||||
returned_cursor = await self.session.fetch_notifications(limit=10)
|
||||
|
||||
self.session.util.get_notifications.assert_called_once_with(limit=10, cursor=None)
|
||||
self.session._handle_like_notification.assert_called_once_with(mock_like_notif)
|
||||
self.session._handle_mention_notification.assert_called_once_with(mock_mention_notif)
|
||||
self.assertEqual(returned_cursor, "next_notif_cursor")
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
||||
# Minimal wx mock for running tests headlessly
|
||||
if 'wx' not in sys.modules: # type: ignore
|
||||
sys.modules['wx'] = MagicMock()
|
||||
mock_wx_module = sys.modules['wx']
|
||||
mock_wx_module.ID_OK = 1
|
||||
mock_wx_module.ID_CANCEL = 2
|
||||
mock_wx_module.ICON_ERROR = 16
|
||||
mock_wx_module.ICON_INFORMATION = 64
|
||||
mock_wx_module.OK = 4
|
||||
mock_wx_module.TextEntryDialog = MockWxDialog
|
||||
mock_wx_module.PasswordEntryDialog = MockWxDialog
|
||||
mock_wx_module.MessageBox = MockWxMessageBox
|
||||
mock_wx_module.CallAfter = MagicMock()
|
||||
mock_wx_module.GetApp = MagicMock()
|
||||
@@ -0,0 +1,167 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import wx
|
||||
import languageHandler
|
||||
from multiplatform_widgets import widgets
|
||||
|
||||
class HomePanel(wx.Panel):
|
||||
def __init__(self, parent, name, account="Unknown"):
|
||||
super().__init__(parent, name=name)
|
||||
self.name = name
|
||||
self.account = account
|
||||
self.type = "home_timeline"
|
||||
|
||||
self.sizer = wx.BoxSizer(wx.VERTICAL)
|
||||
|
||||
# List
|
||||
self.list = widgets.list(self, _("Author"), _("Post"), _("Date"), style=wx.LC_REPORT | wx.LC_SINGLE_SEL | wx.LC_VRULES)
|
||||
self.list.set_windows_size(0, 120)
|
||||
self.list.set_windows_size(1, 400)
|
||||
self.list.set_windows_size(2, 120)
|
||||
self.list.set_size()
|
||||
|
||||
# Buttons
|
||||
self.post = wx.Button(self, -1, _("Post"))
|
||||
self.repost = wx.Button(self, -1, _("Repost"))
|
||||
self.reply = wx.Button(self, -1, _("Reply"))
|
||||
self.like = wx.Button(self, wx.ID_ANY, _("Like"))
|
||||
# self.bookmark = wx.Button(self, wx.ID_ANY, _("Bookmark")) # Not yet common in Bsky API usage here
|
||||
self.dm = wx.Button(self, -1, _("Chat"))
|
||||
|
||||
btnSizer = wx.BoxSizer(wx.HORIZONTAL)
|
||||
btnSizer.Add(self.post, 0, wx.ALL, 5)
|
||||
btnSizer.Add(self.repost, 0, wx.ALL, 5)
|
||||
btnSizer.Add(self.reply, 0, wx.ALL, 5)
|
||||
btnSizer.Add(self.like, 0, wx.ALL, 5)
|
||||
# btnSizer.Add(self.bookmark, 0, wx.ALL, 5)
|
||||
btnSizer.Add(self.dm, 0, wx.ALL, 5)
|
||||
|
||||
self.sizer.Add(btnSizer, 0, wx.ALL, 5)
|
||||
|
||||
self.sizer.Add(self.list.list, 1, wx.EXPAND | wx.ALL, 5)
|
||||
self.SetSizer(self.sizer)
|
||||
|
||||
# Some helper methods expected by controller might be needed?
|
||||
# Controller accesses self.buffer.list directly.
|
||||
# Some older code expected .set_position, .post, .message, .actions attributes or buttons on the panel?
|
||||
# Mastodon panels usually have bottom buttons (Post, Reply, etc).
|
||||
# I should add them if I want to "reuse Mastodon".
|
||||
|
||||
# But for now, simple list is what the previous code had.
|
||||
|
||||
def set_focus_function(self, func):
|
||||
self.list.list.Bind(wx.EVT_LIST_ITEM_FOCUSED, func)
|
||||
|
||||
def set_position(self, reverse):
|
||||
if reverse:
|
||||
self.list.select_item(0)
|
||||
else:
|
||||
self.list.select_item(self.list.get_count() - 1)
|
||||
|
||||
def set_focus_in_list(self):
|
||||
self.list.list.SetFocus()
|
||||
|
||||
class NotificationPanel(HomePanel):
|
||||
pass
|
||||
|
||||
class UserPanel(wx.Panel):
|
||||
def __init__(self, parent, name, account="Unknown"):
|
||||
super().__init__(parent, name=name)
|
||||
self.name = name
|
||||
self.account = account
|
||||
self.type = "user"
|
||||
|
||||
self.sizer = wx.BoxSizer(wx.VERTICAL)
|
||||
|
||||
# List: User
|
||||
self.list = widgets.list(self, _("User"), style=wx.LC_REPORT | wx.LC_SINGLE_SEL | wx.LC_VRULES)
|
||||
self.list.set_windows_size(0, 600)
|
||||
self.list.set_size()
|
||||
|
||||
# Buttons
|
||||
self.post = wx.Button(self, -1, _("Post"))
|
||||
self.actions = wx.Button(self, -1, _("Actions"))
|
||||
self.message = wx.Button(self, -1, _("Message"))
|
||||
|
||||
btnSizer = wx.BoxSizer(wx.HORIZONTAL)
|
||||
btnSizer.Add(self.post, 0, wx.ALL, 5)
|
||||
btnSizer.Add(self.actions, 0, wx.ALL, 5)
|
||||
btnSizer.Add(self.message, 0, wx.ALL, 5)
|
||||
|
||||
self.sizer.Add(btnSizer, 0, wx.ALL, 5)
|
||||
|
||||
self.sizer.Add(self.list.list, 1, wx.EXPAND | wx.ALL, 5)
|
||||
self.SetSizer(self.sizer)
|
||||
|
||||
def set_focus_function(self, func):
|
||||
self.list.list.Bind(wx.EVT_LIST_ITEM_FOCUSED, func)
|
||||
|
||||
def set_position(self, reverse):
|
||||
if reverse:
|
||||
self.list.select_item(0)
|
||||
else:
|
||||
self.list.select_item(self.list.get_count() - 1)
|
||||
|
||||
def set_focus_in_list(self):
|
||||
self.list.list.SetFocus()
|
||||
|
||||
class ChatPanel(wx.Panel):
|
||||
"""Panel for conversation list, similar to Mastodon's conversationListPanel."""
|
||||
def __init__(self, parent, name, account="Unknown"):
|
||||
super().__init__(parent, name=name)
|
||||
self.name = name
|
||||
self.account = account
|
||||
self.type = "chat"
|
||||
|
||||
self.sizer = wx.BoxSizer(wx.VERTICAL)
|
||||
|
||||
# List: User, Text, Date (like Mastodon)
|
||||
self.list = widgets.list(self, _("User"), _("Text"), _("Date"), style=wx.LC_REPORT | wx.LC_SINGLE_SEL | wx.LC_VRULES)
|
||||
self.list.set_windows_size(0, 200)
|
||||
self.list.set_windows_size(1, 600)
|
||||
self.list.set_windows_size(2, 200)
|
||||
self.list.set_size()
|
||||
|
||||
# Buttons (like Mastodon: Post, Reply)
|
||||
self.post = wx.Button(self, -1, _("Post"))
|
||||
self.reply = wx.Button(self, -1, _("Reply"))
|
||||
self.new_chat = wx.Button(self, -1, _("New Chat"))
|
||||
btnSizer = wx.BoxSizer(wx.HORIZONTAL)
|
||||
btnSizer.Add(self.post, 0, wx.ALL, 5)
|
||||
btnSizer.Add(self.reply, 0, wx.ALL, 5)
|
||||
btnSizer.Add(self.new_chat, 0, wx.ALL, 5)
|
||||
self.sizer.Add(btnSizer, 0, wx.ALL, 5)
|
||||
|
||||
self.sizer.Add(self.list.list, 1, wx.EXPAND | wx.ALL, 5)
|
||||
self.SetSizer(self.sizer)
|
||||
|
||||
def set_focus_function(self, func):
|
||||
self.list.list.Bind(wx.EVT_LIST_ITEM_FOCUSED, func)
|
||||
|
||||
def set_position(self, reversed=False):
|
||||
if reversed == False:
|
||||
self.list.select_item(self.list.get_count()-1)
|
||||
else:
|
||||
self.list.select_item(0)
|
||||
|
||||
def set_focus_in_list(self):
|
||||
self.list.list.SetFocus()
|
||||
|
||||
class ChatMessagePanel(HomePanel):
|
||||
def __init__(self, parent, name, account="Unknown"):
|
||||
super().__init__(parent, name, account)
|
||||
self.type = "chat_messages"
|
||||
# Adjust buttons for chat
|
||||
self.repost.Hide()
|
||||
self.like.Hide()
|
||||
self.dm.Hide() # Hide Chat button since we're already in a chat
|
||||
self.reply.SetLabel(_("Send Message"))
|
||||
|
||||
# Refresh columns
|
||||
self.list.list.ClearAll()
|
||||
self.list.list.InsertColumn(0, _("Sender"))
|
||||
self.list.list.InsertColumn(1, _("Message"))
|
||||
self.list.list.InsertColumn(2, _("Date"))
|
||||
self.list.set_windows_size(0, 100)
|
||||
self.list.set_windows_size(1, 400)
|
||||
self.list.set_windows_size(2, 100)
|
||||
self.list.set_size()
|
||||
@@ -59,3 +59,8 @@ def remove_filter():
|
||||
return dlg.ShowModal()
|
||||
def error_removing_filters():
|
||||
return wx.MessageDialog(None, _("TWBlue was unable to remove the filter you specified. Please try again."), _("Error"), wx.ICON_ERROR).ShowModal()
|
||||
|
||||
def common_error(message):
|
||||
"""Show a generic error dialog with the provided message."""
|
||||
dlg = wx.MessageDialog(None, message, _("Error"), wx.OK | wx.ICON_ERROR)
|
||||
return dlg.ShowModal()
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import wx
|
||||
import languageHandler
|
||||
|
||||
|
||||
class AccountSettingsDialog(wx.Dialog):
|
||||
def __init__(self, parent=None, ask_before_boost=True):
|
||||
super(AccountSettingsDialog, self).__init__(parent, title=_("Bluesky Account Settings"))
|
||||
panel = wx.Panel(self)
|
||||
|
||||
sizer = wx.BoxSizer(wx.VERTICAL)
|
||||
|
||||
# Ask before boost/share
|
||||
self.ask_before_boost = wx.CheckBox(panel, wx.ID_ANY, _("Ask confirmation before sharing a post"))
|
||||
self.ask_before_boost.SetValue(bool(ask_before_boost))
|
||||
sizer.Add(self.ask_before_boost, 0, wx.ALL, 8)
|
||||
|
||||
templates_box = wx.StaticBoxSizer(wx.StaticBox(panel, wx.ID_ANY, _("Templates")), wx.VERTICAL)
|
||||
self.template_post = wx.Button(panel, wx.ID_ANY, _("Edit template for posts"))
|
||||
self.template_person = wx.Button(panel, wx.ID_ANY, _("Edit template for persons"))
|
||||
self.template_notification = wx.Button(panel, wx.ID_ANY, _("Edit template for notifications"))
|
||||
templates_box.Add(self.template_post, 0, wx.ALL, 4)
|
||||
templates_box.Add(self.template_person, 0, wx.ALL, 4)
|
||||
templates_box.Add(self.template_notification, 0, wx.ALL, 4)
|
||||
sizer.Add(templates_box, 0, wx.EXPAND | wx.ALL, 8)
|
||||
|
||||
# Buttons
|
||||
btn_sizer = self.CreateSeparatedButtonSizer(wx.OK | wx.CANCEL)
|
||||
|
||||
panel.SetSizer(sizer)
|
||||
|
||||
main = wx.BoxSizer(wx.VERTICAL)
|
||||
main.Add(panel, 1, wx.EXPAND | wx.ALL, 10)
|
||||
if btn_sizer:
|
||||
main.Add(btn_sizer, 0, wx.EXPAND | wx.ALL, 10)
|
||||
self.SetSizerAndFit(main)
|
||||
|
||||
def get_values(self):
|
||||
return {
|
||||
"ask_before_boost": self.ask_before_boost.GetValue(),
|
||||
}
|
||||
|
||||
def set_template_labels(self, post_template, person_template, notification_template):
|
||||
self.template_post.SetLabel(_("Edit template for posts. Current template: {}").format(post_template))
|
||||
self.template_person.SetLabel(_("Edit template for persons. Current template: {}").format(person_template))
|
||||
self.template_notification.SetLabel(_("Edit template for notifications. Current template: {}").format(notification_template))
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Context menus for Bluesky buffers."""
|
||||
|
||||
import wx
|
||||
|
||||
|
||||
class baseMenu(wx.Menu):
|
||||
"""Base context menu for Bluesky posts."""
|
||||
|
||||
def __init__(self):
|
||||
super(baseMenu, self).__init__()
|
||||
self.repost = wx.MenuItem(self, wx.ID_ANY, _("&Repost"))
|
||||
self.Append(self.repost)
|
||||
self.quote = wx.MenuItem(self, wx.ID_ANY, _("&Quote"))
|
||||
self.Append(self.quote)
|
||||
self.reply = wx.MenuItem(self, wx.ID_ANY, _("Re&ply"))
|
||||
self.Append(self.reply)
|
||||
self.like = wx.MenuItem(self, wx.ID_ANY, _("&Like"))
|
||||
self.Append(self.like)
|
||||
self.unlike = wx.MenuItem(self, wx.ID_ANY, _("&Unlike"))
|
||||
self.Append(self.unlike)
|
||||
self.openUrl = wx.MenuItem(self, wx.ID_ANY, _("&Open URL"))
|
||||
self.Append(self.openUrl)
|
||||
self.openInBrowser = wx.MenuItem(self, wx.ID_ANY, _("Open in &browser"))
|
||||
self.Append(self.openInBrowser)
|
||||
self.view = wx.MenuItem(self, wx.ID_ANY, _("&Show post"))
|
||||
self.Append(self.view)
|
||||
self.copy = wx.MenuItem(self, wx.ID_ANY, _("&Copy to clipboard"))
|
||||
self.Append(self.copy)
|
||||
self.remove = wx.MenuItem(self, wx.ID_ANY, _("&Delete"))
|
||||
self.Append(self.remove)
|
||||
self.userActions = wx.MenuItem(self, wx.ID_ANY, _("&User actions..."))
|
||||
self.Append(self.userActions)
|
||||
|
||||
|
||||
class notificationMenu(wx.Menu):
|
||||
"""Context menu for Bluesky notifications."""
|
||||
|
||||
def __init__(self, notification_type="like"):
|
||||
super(notificationMenu, self).__init__()
|
||||
# Notification types that have associated posts
|
||||
post_types = ["like", "repost", "mention", "reply", "quote"]
|
||||
|
||||
if notification_type in post_types:
|
||||
self.repost = wx.MenuItem(self, wx.ID_ANY, _("&Repost"))
|
||||
self.Append(self.repost)
|
||||
self.reply = wx.MenuItem(self, wx.ID_ANY, _("Re&ply"))
|
||||
self.Append(self.reply)
|
||||
self.like = wx.MenuItem(self, wx.ID_ANY, _("&Like"))
|
||||
self.Append(self.like)
|
||||
self.openUrl = wx.MenuItem(self, wx.ID_ANY, _("&Open URL"))
|
||||
self.Append(self.openUrl)
|
||||
|
||||
self.openInBrowser = wx.MenuItem(self, wx.ID_ANY, _("Open in &browser"))
|
||||
self.Append(self.openInBrowser)
|
||||
self.view = wx.MenuItem(self, wx.ID_ANY, _("&Show post"))
|
||||
self.Append(self.view)
|
||||
self.copy = wx.MenuItem(self, wx.ID_ANY, _("&Copy to clipboard"))
|
||||
self.Append(self.copy)
|
||||
self.userActions = wx.MenuItem(self, wx.ID_ANY, _("&User actions..."))
|
||||
self.Append(self.userActions)
|
||||
|
||||
|
||||
class userMenu(wx.Menu):
|
||||
"""Context menu for Bluesky user lists."""
|
||||
|
||||
def __init__(self):
|
||||
super(userMenu, self).__init__()
|
||||
self.timeline = wx.MenuItem(self, wx.ID_ANY, _("View &timeline"))
|
||||
self.Append(self.timeline)
|
||||
self.followers = wx.MenuItem(self, wx.ID_ANY, _("View f&ollowers"))
|
||||
self.Append(self.followers)
|
||||
self.following = wx.MenuItem(self, wx.ID_ANY, _("View &following"))
|
||||
self.Append(self.following)
|
||||
self.dm = wx.MenuItem(self, wx.ID_ANY, _("Send &message"))
|
||||
self.Append(self.dm)
|
||||
self.view = wx.MenuItem(self, wx.ID_ANY, _("View &profile"))
|
||||
self.Append(self.view)
|
||||
self.copy = wx.MenuItem(self, wx.ID_ANY, _("&Copy to clipboard"))
|
||||
self.Append(self.copy)
|
||||
self.userActions = wx.MenuItem(self, wx.ID_ANY, _("&User actions..."))
|
||||
self.Append(self.userActions)
|
||||
|
||||
|
||||
class chatMenu(wx.Menu):
|
||||
"""Context menu for Bluesky chat messages."""
|
||||
|
||||
def __init__(self):
|
||||
super(chatMenu, self).__init__()
|
||||
self.reply = wx.MenuItem(self, wx.ID_ANY, _("&Reply"))
|
||||
self.Append(self.reply)
|
||||
self.copy = wx.MenuItem(self, wx.ID_ANY, _("&Copy to clipboard"))
|
||||
self.Append(self.copy)
|
||||
self.view = wx.MenuItem(self, wx.ID_ANY, _("&Show message"))
|
||||
self.Append(self.view)
|
||||
self.userActions = wx.MenuItem(self, wx.ID_ANY, _("&User actions..."))
|
||||
self.Append(self.userActions)
|
||||
@@ -0,0 +1,292 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import wx
|
||||
|
||||
|
||||
class Post(wx.Dialog):
|
||||
def __init__(self, caption=_("Post"), text="", languages=[], *args, **kwds):
|
||||
super(Post, self).__init__(parent=None, id=wx.ID_ANY, *args, **kwds)
|
||||
self.SetTitle(caption)
|
||||
main_sizer = wx.BoxSizer(wx.VERTICAL)
|
||||
|
||||
# Text
|
||||
post_label = wx.StaticText(self, wx.ID_ANY, caption)
|
||||
main_sizer.Add(post_label, 0, wx.ALL, 6)
|
||||
self.text = wx.TextCtrl(self, wx.ID_ANY, text, style=wx.TE_MULTILINE)
|
||||
self.Bind(wx.EVT_CHAR_HOOK, self.handle_keys, self.text)
|
||||
self.text.SetMinSize((400, 160))
|
||||
main_sizer.Add(self.text, 1, wx.EXPAND | wx.ALL, 6)
|
||||
|
||||
# Sensitive + CW
|
||||
self.sensitive = wx.CheckBox(self, wx.ID_ANY, _("S&ensitive content"))
|
||||
self.sensitive.SetValue(False)
|
||||
self.sensitive.Bind(wx.EVT_CHECKBOX, self.on_sensitivity_changed)
|
||||
main_sizer.Add(self.sensitive, 0, wx.ALL, 5)
|
||||
|
||||
spoiler_box = wx.BoxSizer(wx.HORIZONTAL)
|
||||
spoiler_label = wx.StaticText(self, wx.ID_ANY, _("Content warning"))
|
||||
self.spoiler = wx.TextCtrl(self, wx.ID_ANY)
|
||||
self.spoiler.Enable(False)
|
||||
spoiler_box.Add(spoiler_label, 0, wx.ALL, 5)
|
||||
spoiler_box.Add(self.spoiler, 1, wx.ALL, 10)
|
||||
main_sizer.Add(spoiler_box, 0, wx.EXPAND | wx.ALL, 5)
|
||||
|
||||
# Attachments (images only)
|
||||
attach_box = wx.StaticBoxSizer(wx.VERTICAL, self, _("Attachments (images)"))
|
||||
self.attach_list = wx.ListCtrl(self, style=wx.LC_REPORT | wx.LC_SINGLE_SEL)
|
||||
self.attach_list.InsertColumn(0, _("File"))
|
||||
self.attach_list.InsertColumn(1, _("Alt"))
|
||||
attach_box.Add(self.attach_list, 1, wx.EXPAND | wx.ALL, 5)
|
||||
btn_row = wx.BoxSizer(wx.HORIZONTAL)
|
||||
self.btn_add = wx.Button(self, wx.ID_ADD, _("Add image..."))
|
||||
self.btn_remove = wx.Button(self, wx.ID_REMOVE, _("Remove"))
|
||||
self.btn_remove.Enable(False)
|
||||
btn_row.Add(self.btn_add, 0, wx.ALL, 2)
|
||||
btn_row.Add(self.btn_remove, 0, wx.ALL, 2)
|
||||
attach_box.Add(btn_row, 0, wx.ALIGN_LEFT)
|
||||
main_sizer.Add(attach_box, 0, wx.EXPAND | wx.LEFT | wx.RIGHT | wx.BOTTOM, 6)
|
||||
|
||||
# Language (single optional)
|
||||
lang_row = wx.BoxSizer(wx.HORIZONTAL)
|
||||
lang_row.Add(wx.StaticText(self, label=_("&Language")), 0, wx.ALIGN_CENTER_VERTICAL | wx.RIGHT, 4)
|
||||
self.language = wx.ComboBox(self, wx.ID_ANY, choices=languages, style=wx.CB_DROPDOWN | wx.CB_READONLY)
|
||||
self.language.SetSelection(0)
|
||||
lang_row.Add(self.language, 0, wx.ALIGN_CENTER_VERTICAL)
|
||||
main_sizer.Add(lang_row, 0, wx.LEFT | wx.RIGHT | wx.BOTTOM, 6)
|
||||
|
||||
# Text actions (spellcheck, translate, autocomplete)
|
||||
text_actions_sizer = wx.BoxSizer(wx.HORIZONTAL)
|
||||
self.autocomplete_users = wx.Button(self, wx.ID_ANY, _("Auto&complete users"))
|
||||
text_actions_sizer.Add(self.autocomplete_users, 0, wx.ALL, 2)
|
||||
self.spellcheck = wx.Button(self, wx.ID_ANY, _("Check &spelling"))
|
||||
text_actions_sizer.Add(self.spellcheck, 0, wx.ALL, 2)
|
||||
self.translate = wx.Button(self, wx.ID_ANY, _("&Translate"))
|
||||
text_actions_sizer.Add(self.translate, 0, wx.ALL, 2)
|
||||
main_sizer.Add(text_actions_sizer, 0, wx.LEFT | wx.RIGHT | wx.BOTTOM, 6)
|
||||
|
||||
# Buttons
|
||||
btn_sizer = wx.StdDialogButtonSizer()
|
||||
self.send = wx.Button(self, wx.ID_ANY, _("&Send"))
|
||||
self.send.SetDefault()
|
||||
self.send.Bind(wx.EVT_BUTTON, lambda evt: self.EndModal(wx.ID_OK))
|
||||
btn_sizer.AddButton(self.send)
|
||||
self.close = wx.Button(self, wx.ID_CLOSE, "")
|
||||
btn_sizer.AddButton(self.close)
|
||||
btn_sizer.Realize()
|
||||
main_sizer.Add(btn_sizer, 0, wx.ALIGN_RIGHT | wx.ALL, 4)
|
||||
|
||||
self.SetSizer(main_sizer)
|
||||
main_sizer.Fit(self)
|
||||
self.SetEscapeId(self.close.GetId())
|
||||
self.Layout()
|
||||
|
||||
# Bindings
|
||||
self.btn_add.Bind(wx.EVT_BUTTON, self.on_add)
|
||||
self.btn_remove.Bind(wx.EVT_BUTTON, self.on_remove)
|
||||
self.attach_list.Bind(wx.EVT_LIST_ITEM_SELECTED, lambda evt: self.btn_remove.Enable(True))
|
||||
self.attach_list.Bind(wx.EVT_LIST_ITEM_DESELECTED, lambda evt: self.btn_remove.Enable(False))
|
||||
|
||||
def handle_keys(self, event):
|
||||
shift = event.ShiftDown()
|
||||
if event.GetKeyCode() == wx.WXK_RETURN and not shift and hasattr(self, "send"):
|
||||
self.EndModal(wx.ID_OK)
|
||||
else:
|
||||
event.Skip()
|
||||
|
||||
def on_sensitivity_changed(self, *args, **kwargs):
|
||||
self.spoiler.Enable(self.sensitive.GetValue())
|
||||
|
||||
def on_add(self, evt):
|
||||
if self.attach_list.GetItemCount() >= 4:
|
||||
wx.MessageBox(_("You can attach up to 4 images."), _("Attachment limit"), wx.ICON_INFORMATION)
|
||||
return
|
||||
fd = wx.FileDialog(self, _("Select image"), wildcard=_("Image files (*.png;*.jpg;*.jpeg;*.gif)|*.png;*.jpg;*.jpeg;*.gif"), style=wx.FD_OPEN | wx.FD_FILE_MUST_EXIST)
|
||||
if fd.ShowModal() != wx.ID_OK:
|
||||
fd.Destroy()
|
||||
return
|
||||
path = fd.GetPath()
|
||||
fd.Destroy()
|
||||
alt_dlg = wx.TextEntryDialog(self, _("Alternative text (optional)"), _("Description"))
|
||||
alt = ""
|
||||
if alt_dlg.ShowModal() == wx.ID_OK:
|
||||
alt = alt_dlg.GetValue()
|
||||
alt_dlg.Destroy()
|
||||
idx = self.attach_list.InsertItem(self.attach_list.GetItemCount(), path)
|
||||
self.attach_list.SetItem(idx, 1, alt)
|
||||
|
||||
def on_remove(self, evt):
|
||||
sel = self.attach_list.GetFirstSelected()
|
||||
if sel != -1:
|
||||
self.attach_list.DeleteItem(sel)
|
||||
|
||||
def get_payload(self):
|
||||
text = self.text.GetValue().strip()
|
||||
cw_text = self.spoiler.GetValue().strip() if self.sensitive.GetValue() else None
|
||||
lang_index = self.language.GetSelection()
|
||||
files = []
|
||||
for i in range(self.attach_list.GetItemCount()):
|
||||
files.append({
|
||||
"path": self.attach_list.GetItemText(i, 0),
|
||||
"alt": self.attach_list.GetItemText(i, 1),
|
||||
})
|
||||
return text, files, cw_text, lang_index
|
||||
|
||||
|
||||
class viewPost(wx.Dialog):
|
||||
def set_title(self, length):
|
||||
self.SetTitle(_("Post - %i characters ") % length)
|
||||
|
||||
def __init__(self, text="", reposts_count=0, likes_count=0, source="", date="", privacy="", *args, **kwargs):
|
||||
super(viewPost, self).__init__(parent=None, id=wx.ID_ANY, size=(850, 850))
|
||||
self.init_ui(text, reposts_count, likes_count, source, date, privacy)
|
||||
|
||||
def init_ui(self, text, reposts_count, likes_count, source, date, privacy):
|
||||
panel = wx.Panel(self)
|
||||
main_sizer = wx.BoxSizer(wx.VERTICAL)
|
||||
main_sizer.Add(self.create_text_section(panel, text), 1, wx.EXPAND | wx.ALL, 5)
|
||||
main_sizer.Add(self.create_image_description_section(panel), 1, wx.EXPAND | wx.ALL, 5)
|
||||
main_sizer.Add(self.create_info_section(panel, privacy, reposts_count, likes_count, source, date), 0, wx.EXPAND | wx.ALL, 5)
|
||||
main_sizer.Add(self.create_buttons_section(panel), 0, wx.ALIGN_RIGHT | wx.ALL, 5)
|
||||
panel.SetSizer(main_sizer)
|
||||
self.SetClientSize(main_sizer.CalcMin())
|
||||
|
||||
def create_text_section(self, panel, text):
|
||||
sizer = wx.StaticBoxSizer(wx.StaticBox(panel, wx.ID_ANY, _("Post")), wx.VERTICAL)
|
||||
self.text = wx.TextCtrl(panel, -1, text, style=wx.TE_READONLY | wx.TE_MULTILINE)
|
||||
sizer.Add(self.text, 1, wx.EXPAND | wx.ALL, 5)
|
||||
return sizer
|
||||
|
||||
def create_image_description_section(self, panel):
|
||||
sizer = wx.StaticBoxSizer(wx.StaticBox(panel, wx.ID_ANY, _("Image description")), wx.VERTICAL)
|
||||
self.image_description = wx.TextCtrl(panel, -1, style=wx.TE_READONLY | wx.TE_MULTILINE)
|
||||
self.image_description.Enable(False)
|
||||
sizer.Add(self.image_description, 1, wx.EXPAND | wx.ALL, 5)
|
||||
return sizer
|
||||
|
||||
def create_info_section(self, panel, privacy, reposts_count, likes_count, source, date):
|
||||
sizer = wx.StaticBoxSizer(wx.StaticBox(panel, wx.ID_ANY, _("Information")), wx.VERTICAL)
|
||||
flex_sizer = wx.FlexGridSizer(cols=3, hgap=10, vgap=10)
|
||||
flex_sizer.AddGrowableCol(1)
|
||||
flex_sizer.Add(wx.StaticText(panel, -1, _("Privacy")), 0, wx.ALIGN_CENTER_VERTICAL)
|
||||
flex_sizer.Add(wx.TextCtrl(panel, -1, privacy, style=wx.TE_READONLY | wx.TE_MULTILINE), 1, wx.EXPAND)
|
||||
flex_sizer.Add(self.create_reposts_section(panel, reposts_count), 1, wx.EXPAND | wx.ALL, 5)
|
||||
flex_sizer.Add(self.create_likes_section(panel, likes_count), 1, wx.EXPAND | wx.ALL, 5)
|
||||
flex_sizer.Add(wx.StaticText(panel, -1, _("Source")), 0, wx.ALIGN_CENTER_VERTICAL)
|
||||
flex_sizer.Add(wx.TextCtrl(panel, -1, source, style=wx.TE_READONLY | wx.TE_MULTILINE), 1, wx.EXPAND)
|
||||
flex_sizer.Add(wx.StaticText(panel, -1, _("Date")), 0, wx.ALIGN_CENTER_VERTICAL)
|
||||
flex_sizer.Add(wx.TextCtrl(panel, -1, date, style=wx.TE_READONLY | wx.TE_MULTILINE), 1, wx.EXPAND)
|
||||
sizer.Add(flex_sizer, 1, wx.EXPAND | wx.ALL, 5)
|
||||
return sizer
|
||||
|
||||
def create_reposts_section(self, panel, reposts_count):
|
||||
sizer = wx.StaticBoxSizer(wx.StaticBox(panel, wx.ID_ANY, _("Reposts")), wx.VERTICAL)
|
||||
self.reposts_button = wx.Button(panel, -1, str(reposts_count))
|
||||
self.reposts_button.Enable(False)
|
||||
sizer.Add(self.reposts_button, 1, wx.EXPAND | wx.ALL, 5)
|
||||
return sizer
|
||||
|
||||
def create_likes_section(self, panel, likes_count):
|
||||
sizer = wx.StaticBoxSizer(wx.StaticBox(panel, wx.ID_ANY, _("Likes")), wx.VERTICAL)
|
||||
self.likes_button = wx.Button(panel, -1, str(likes_count))
|
||||
self.likes_button.Enable(False)
|
||||
sizer.Add(self.likes_button, 1, wx.EXPAND | wx.ALL, 5)
|
||||
return sizer
|
||||
|
||||
def create_buttons_section(self, panel):
|
||||
sizer = wx.BoxSizer(wx.HORIZONTAL)
|
||||
self.share = wx.Button(panel, wx.ID_ANY, _("&Copy link to clipboard"))
|
||||
self.share.Enable(False)
|
||||
self.spellcheck = wx.Button(panel, wx.ID_ANY, _("Check &spelling..."))
|
||||
self.translateButton = wx.Button(panel, wx.ID_ANY, _("&Translate..."))
|
||||
cancelButton = wx.Button(panel, wx.ID_CANCEL, _("C&lose"))
|
||||
cancelButton.SetDefault()
|
||||
sizer.Add(self.share, 0, wx.ALL, 5)
|
||||
sizer.Add(self.spellcheck, 0, wx.ALL, 5)
|
||||
sizer.Add(self.translateButton, 0, wx.ALL, 5)
|
||||
sizer.Add(cancelButton, 0, wx.ALL, 5)
|
||||
return sizer
|
||||
|
||||
def set_text(self, text):
|
||||
self.text.ChangeValue(text)
|
||||
|
||||
def get_text(self):
|
||||
return self.text.GetValue()
|
||||
|
||||
def text_focus(self):
|
||||
self.text.SetFocus()
|
||||
|
||||
def onSelect(self, ev):
|
||||
self.text.SelectAll()
|
||||
|
||||
def enable_button(self, buttonName):
|
||||
if hasattr(self, buttonName):
|
||||
return getattr(self, buttonName).Enable()
|
||||
|
||||
|
||||
class viewText(wx.Dialog):
|
||||
def __init__(self, title="", text="", *args, **kwargs):
|
||||
super(viewText, self).__init__(parent=None, id=wx.ID_ANY, size=(850, 850), title=title)
|
||||
panel = wx.Panel(self)
|
||||
label = wx.StaticText(panel, -1, _("Text"))
|
||||
self.text = wx.TextCtrl(panel, -1, text, style=wx.TE_READONLY | wx.TE_MULTILINE)
|
||||
self.text.SetMinSize((500, 300))
|
||||
self.text.SetFocus()
|
||||
textBox = wx.BoxSizer(wx.HORIZONTAL)
|
||||
textBox.Add(label, 0, wx.ALL, 5)
|
||||
textBox.Add(self.text, 1, wx.EXPAND, 5)
|
||||
mainBox = wx.BoxSizer(wx.VERTICAL)
|
||||
mainBox.Add(textBox, 1, wx.EXPAND | wx.ALL, 5)
|
||||
self.spellcheck = wx.Button(panel, -1, _("Check &spelling..."), size=wx.DefaultSize)
|
||||
self.translateButton = wx.Button(panel, -1, _("&Translate..."), size=wx.DefaultSize)
|
||||
cancelButton = wx.Button(panel, wx.ID_CANCEL, _("C&lose"), size=wx.DefaultSize)
|
||||
cancelButton.SetDefault()
|
||||
buttonsBox = wx.BoxSizer(wx.HORIZONTAL)
|
||||
buttonsBox.Add(self.spellcheck, 0, wx.ALL, 5)
|
||||
buttonsBox.Add(self.translateButton, 0, wx.ALL, 5)
|
||||
buttonsBox.Add(cancelButton, 0, wx.ALL, 5)
|
||||
mainBox.Add(buttonsBox, 0, wx.ALL, 5)
|
||||
panel.SetSizer(mainBox)
|
||||
self.SetMinSize((600, 400))
|
||||
self.Layout()
|
||||
|
||||
|
||||
class RepostDialog(wx.Dialog):
|
||||
def __init__(self):
|
||||
super(RepostDialog, self).__init__(None, title=_("Repost"))
|
||||
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_repost = wx.Button(p, wx.ID_ANY, _("Repost"))
|
||||
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_repost, 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_repost.Bind(wx.EVT_BUTTON, self.on_repost)
|
||||
self.btn_quote.Bind(wx.EVT_BUTTON, self.on_quote)
|
||||
self.result = 0
|
||||
|
||||
def on_repost(self, event):
|
||||
self.result = 1
|
||||
self.EndModal(wx.ID_OK)
|
||||
|
||||
def on_quote(self, event):
|
||||
self.result = 2
|
||||
self.EndModal(wx.ID_OK)
|
||||
|
||||
|
||||
def repost_question():
|
||||
dlg = RepostDialog()
|
||||
dlg.ShowModal()
|
||||
result = dlg.result
|
||||
dlg.Destroy()
|
||||
return result
|
||||
|
||||
@@ -0,0 +1,412 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import wx
|
||||
import logging
|
||||
import languageHandler
|
||||
import builtins
|
||||
import requests
|
||||
from io import BytesIO
|
||||
from threading import Thread
|
||||
from pubsub import pub
|
||||
|
||||
_ = getattr(builtins, "_", lambda s: s)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def returnTrue():
|
||||
return True
|
||||
|
||||
class ShowUserProfileDialog(wx.Dialog):
|
||||
def __init__(self, parent, session, user_identifier: str): # user_identifier can be DID or handle
|
||||
super(ShowUserProfileDialog, self).__init__(parent, title=_("User Profile"), style=wx.DEFAULT_DIALOG_STYLE | wx.RESIZE_BORDER)
|
||||
|
||||
self.session = session
|
||||
self.user_identifier = user_identifier
|
||||
self.profile_data = None # Will store the formatted profile dict
|
||||
self.target_user_did = None # Will store the resolved DID of the profile being viewed
|
||||
|
||||
self._init_ui()
|
||||
self.SetMinSize((400, 300))
|
||||
self.CentreOnParent()
|
||||
|
||||
Thread(target=self.load_profile_data, daemon=True).start()
|
||||
|
||||
def _init_ui(self):
|
||||
self.panel = wx.Panel(self)
|
||||
main_sizer = wx.BoxSizer(wx.VERTICAL)
|
||||
|
||||
# Profile Info Section (StaticTexts for labels and values)
|
||||
self.info_grid_sizer = wx.FlexGridSizer(cols=2, vgap=5, hgap=5)
|
||||
self.info_grid_sizer.AddGrowableCol(1, 1)
|
||||
|
||||
# Basic text fields (name, handle, bio)
|
||||
fields = [
|
||||
(_("&Name:"), "displayName"), (_("&Handle:"), "handle"),
|
||||
(_("&Bio:"), "description")
|
||||
]
|
||||
self.profile_field_ctrls = {}
|
||||
|
||||
for label_text, data_key in fields:
|
||||
lbl = wx.StaticText(self.panel, label=label_text)
|
||||
style = wx.TE_READONLY | wx.TE_PROCESS_TAB
|
||||
if data_key == "description":
|
||||
style |= wx.TE_MULTILINE
|
||||
else:
|
||||
style |= wx.BORDER_NONE
|
||||
val_ctrl = wx.TextCtrl(self.panel, style=style)
|
||||
if data_key != "description":
|
||||
val_ctrl.SetBackgroundColour(self.panel.GetBackgroundColour())
|
||||
val_ctrl.AcceptsFocusFromKeyboard = returnTrue
|
||||
|
||||
self.info_grid_sizer.Add(lbl, 0, wx.ALIGN_RIGHT | wx.ALIGN_TOP | wx.ALL, 2)
|
||||
self.info_grid_sizer.Add(val_ctrl, 1, wx.EXPAND | wx.ALL, 2)
|
||||
self.profile_field_ctrls[data_key] = val_ctrl
|
||||
|
||||
# Banner image
|
||||
bannerLabel = wx.StaticText(self.panel, label=_("Banner:"))
|
||||
self.bannerImage = wx.StaticBitmap(self.panel)
|
||||
self.bannerImage.AcceptsFocusFromKeyboard = returnTrue
|
||||
self.info_grid_sizer.Add(bannerLabel, 0, wx.ALIGN_RIGHT | wx.ALIGN_TOP | wx.ALL, 2)
|
||||
self.info_grid_sizer.Add(self.bannerImage, 0, wx.ALL, 2)
|
||||
|
||||
# Avatar image
|
||||
avatarLabel = wx.StaticText(self.panel, label=_("Avatar:"))
|
||||
self.avatarImage = wx.StaticBitmap(self.panel)
|
||||
self.avatarImage.AcceptsFocusFromKeyboard = returnTrue
|
||||
self.info_grid_sizer.Add(avatarLabel, 0, wx.ALIGN_RIGHT | wx.ALIGN_TOP | wx.ALL, 2)
|
||||
self.info_grid_sizer.Add(self.avatarImage, 0, wx.ALL, 2)
|
||||
|
||||
main_sizer.Add(self.info_grid_sizer, 1, wx.EXPAND | wx.ALL, 10)
|
||||
|
||||
# Timeline buttons (like Mastodon - with counters)
|
||||
timeline_sizer = wx.BoxSizer(wx.HORIZONTAL)
|
||||
self.posts_btn = wx.Button(self.panel, label=_("0 pos&ts"))
|
||||
self.posts_btn.Bind(wx.EVT_BUTTON, self.onPosts)
|
||||
timeline_sizer.Add(self.posts_btn, 0, wx.ALL, 3)
|
||||
|
||||
self.following_btn = wx.Button(self.panel, label=_("0 &following"))
|
||||
self.following_btn.Bind(wx.EVT_BUTTON, self.onFollowing)
|
||||
timeline_sizer.Add(self.following_btn, 0, wx.ALL, 3)
|
||||
|
||||
self.followers_btn = wx.Button(self.panel, label=_("0 fo&llowers"))
|
||||
self.followers_btn.Bind(wx.EVT_BUTTON, self.onFollowers)
|
||||
timeline_sizer.Add(self.followers_btn, 0, wx.ALL, 3)
|
||||
|
||||
main_sizer.Add(timeline_sizer, 0, wx.ALIGN_CENTER | wx.TOP | wx.BOTTOM, 5)
|
||||
|
||||
# Action Buttons
|
||||
actions_sizer = wx.BoxSizer(wx.HORIZONTAL)
|
||||
self.follow_btn = wx.Button(self.panel, label=_("&Follow"))
|
||||
self.unfollow_btn = wx.Button(self.panel, label=_("U&nfollow"))
|
||||
self.mute_btn = wx.Button(self.panel, label=_("&Mute"))
|
||||
self.unmute_btn = wx.Button(self.panel, label=_("Unmu&te"))
|
||||
self.block_btn = wx.Button(self.panel, label=_("&Block"))
|
||||
self.unblock_btn = wx.Button(self.panel, label=_("Unbl&ock"))
|
||||
|
||||
self.follow_btn.Bind(wx.EVT_BUTTON, lambda evt, cmd="follow_user": self.on_user_action(evt, cmd))
|
||||
self.unfollow_btn.Bind(wx.EVT_BUTTON, lambda evt, cmd="unfollow_user": self.on_user_action(evt, cmd))
|
||||
self.mute_btn.Bind(wx.EVT_BUTTON, lambda evt, cmd="mute_user": self.on_user_action(evt, cmd))
|
||||
self.unmute_btn.Bind(wx.EVT_BUTTON, lambda evt, cmd="unmute_user": self.on_user_action(evt, cmd))
|
||||
self.block_btn.Bind(wx.EVT_BUTTON, lambda evt, cmd="block_user": self.on_user_action(evt, cmd))
|
||||
self.unblock_btn.Bind(wx.EVT_BUTTON, lambda evt, cmd="unblock_user": self.on_user_action(evt, cmd))
|
||||
|
||||
actions_sizer.Add(self.follow_btn, 0, wx.ALL, 3)
|
||||
actions_sizer.Add(self.unfollow_btn, 0, wx.ALL, 3)
|
||||
actions_sizer.Add(self.mute_btn, 0, wx.ALL, 3)
|
||||
actions_sizer.Add(self.unmute_btn, 0, wx.ALL, 3)
|
||||
actions_sizer.Add(self.block_btn, 0, wx.ALL, 3)
|
||||
actions_sizer.Add(self.unblock_btn, 0, wx.ALL, 3)
|
||||
main_sizer.Add(actions_sizer, 0, wx.ALIGN_CENTER | wx.TOP | wx.BOTTOM, 10)
|
||||
|
||||
# Close Button
|
||||
close_btn = wx.Button(self.panel, wx.ID_CANCEL, _("&Close"))
|
||||
close_btn.SetDefault()
|
||||
main_sizer.Add(close_btn, 0, wx.ALIGN_RIGHT | wx.ALL, 10)
|
||||
self.SetEscapeId(close_btn.GetId())
|
||||
|
||||
self.panel.SetSizer(main_sizer)
|
||||
self.Fit()
|
||||
|
||||
def load_profile_data(self):
|
||||
wx.CallAfter(self.SetStatusText, _("Loading profile..."))
|
||||
for ctrl in self.profile_field_ctrls.values():
|
||||
wx.CallAfter(ctrl.SetValue, _("Loading..."))
|
||||
|
||||
# Initially hide all action buttons until state is known
|
||||
wx.CallAfter(self.follow_btn.Hide)
|
||||
wx.CallAfter(self.unfollow_btn.Hide)
|
||||
wx.CallAfter(self.mute_btn.Hide)
|
||||
wx.CallAfter(self.unmute_btn.Hide)
|
||||
wx.CallAfter(self.block_btn.Hide)
|
||||
wx.CallAfter(self.unblock_btn.Hide)
|
||||
|
||||
try:
|
||||
api = self.session._ensure_client()
|
||||
try:
|
||||
raw_profile = api.app.bsky.actor.get_profile({"actor": self.user_identifier})
|
||||
except Exception:
|
||||
raw_profile = None
|
||||
wx.CallAfter(self._apply_profile_data, raw_profile)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading profile for {self.user_identifier}: {e}", exc_info=True)
|
||||
wx.CallAfter(self._apply_profile_error, e)
|
||||
|
||||
def _apply_profile_data(self, raw_profile):
|
||||
if raw_profile:
|
||||
self.profile_data = self._format_profile_data(raw_profile)
|
||||
self.target_user_did = self.profile_data.get("did")
|
||||
self.user_identifier = self.target_user_did or self.user_identifier
|
||||
|
||||
self.update_ui_fields()
|
||||
self.update_action_buttons_state()
|
||||
self.SetTitle(_("Profile: {handle}").format(handle=self.profile_data.get("handle", "")))
|
||||
self.SetStatusText(_("Profile loaded."))
|
||||
else:
|
||||
for ctrl in self.profile_field_ctrls.values():
|
||||
ctrl.SetValue(_("Not found."))
|
||||
self.SetStatusText(_("Profile not found for '{ident}'.").format(ident=self.user_identifier))
|
||||
wx.MessageBox(_("User profile for '{ident}' not found.").format(ident=self.user_identifier), _("Error"), wx.OK | wx.ICON_ERROR, self)
|
||||
self.Layout()
|
||||
|
||||
def _apply_profile_error(self, err):
|
||||
for ctrl in self.profile_field_ctrls.values():
|
||||
ctrl.SetValue(_("Error loading."))
|
||||
self.SetStatusText(_("Error loading profile."))
|
||||
wx.MessageBox(_("Error loading profile: {error}").format(error=str(err)), _("Error"), wx.OK | wx.ICON_ERROR, self)
|
||||
self.Layout()
|
||||
|
||||
def update_ui_fields(self):
|
||||
if not self.profile_data:
|
||||
return
|
||||
|
||||
for key, ctrl in self.profile_field_ctrls.items():
|
||||
value = self.profile_data.get(key)
|
||||
if key == "description" and value:
|
||||
ctrl.SetMinSize((-1, 60))
|
||||
|
||||
if isinstance(value, (int, float)):
|
||||
ctrl.SetValue(str(value))
|
||||
else:
|
||||
ctrl.SetValue(value or _("N/A"))
|
||||
|
||||
# Update timeline buttons with counts
|
||||
posts_count = self.profile_data.get("postsCount") or 0
|
||||
followers_count = self.profile_data.get("followersCount") or 0
|
||||
following_count = self.profile_data.get("followsCount") or 0
|
||||
|
||||
self.posts_btn.SetLabel(_("{count} pos&ts. Click to open posts timeline").format(count=posts_count))
|
||||
self.followers_btn.SetLabel(_("{count} fo&llowers. Click to open followers timeline").format(count=followers_count))
|
||||
self.following_btn.SetLabel(_("{count} &following. Click to open following timeline").format(count=following_count))
|
||||
|
||||
# Start image download in background thread
|
||||
Thread(target=self._download_images, daemon=True).start()
|
||||
self.Layout()
|
||||
|
||||
def _download_images(self):
|
||||
"""Downloads avatar and banner images from Bluesky server."""
|
||||
avatar_url = self.profile_data.get("avatar") if self.profile_data else None
|
||||
banner_url = self.profile_data.get("banner") if self.profile_data else None
|
||||
|
||||
avatar_bytes = None
|
||||
banner_bytes = None
|
||||
|
||||
try:
|
||||
if banner_url:
|
||||
resp = requests.get(banner_url, timeout=10)
|
||||
if resp.status_code == 200:
|
||||
banner_bytes = resp.content
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to download banner: {e}")
|
||||
|
||||
try:
|
||||
if avatar_url:
|
||||
resp = requests.get(avatar_url, timeout=10)
|
||||
if resp.status_code == 200:
|
||||
avatar_bytes = resp.content
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to download avatar: {e}")
|
||||
|
||||
wx.CallAfter(self._draw_images, banner_bytes, avatar_bytes)
|
||||
|
||||
def _draw_images(self, banner_bytes, avatar_bytes):
|
||||
"""Draws downloaded images on the bitmap controls."""
|
||||
try:
|
||||
if banner_bytes:
|
||||
banner_image = wx.Image(BytesIO(banner_bytes), wx.BITMAP_TYPE_ANY)
|
||||
banner_image.Rescale(300, 100, wx.IMAGE_QUALITY_HIGH)
|
||||
self.bannerImage.SetBitmap(banner_image.ConvertToBitmap())
|
||||
|
||||
if avatar_bytes:
|
||||
avatar_image = wx.Image(BytesIO(avatar_bytes), wx.BITMAP_TYPE_ANY)
|
||||
avatar_image.Rescale(150, 150, wx.IMAGE_QUALITY_HIGH)
|
||||
self.avatarImage.SetBitmap(avatar_image.ConvertToBitmap())
|
||||
|
||||
self.Layout()
|
||||
self.Fit()
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to draw images: {e}")
|
||||
|
||||
def onPosts(self, *args):
|
||||
"""Open this user's posts timeline."""
|
||||
if self.profile_data:
|
||||
pub.sendMessage('execute-action', action='openPostTimeline', kwargs=dict(user=self.profile_data))
|
||||
|
||||
def onFollowing(self, *args):
|
||||
"""Open following timeline for this user."""
|
||||
if self.profile_data:
|
||||
pub.sendMessage('execute-action', action='openFollowingTimeline', kwargs=dict(user=self.profile_data))
|
||||
|
||||
def onFollowers(self, *args):
|
||||
"""Open followers timeline for this user."""
|
||||
if self.profile_data:
|
||||
pub.sendMessage('execute-action', action='openFollowersTimeline', kwargs=dict(user=self.profile_data))
|
||||
|
||||
def update_action_buttons_state(self):
|
||||
if not self.profile_data or not self.target_user_did or self.target_user_did == self._get_own_did():
|
||||
self.follow_btn.Hide()
|
||||
self.unfollow_btn.Hide()
|
||||
self.mute_btn.Hide()
|
||||
self.unmute_btn.Hide()
|
||||
self.block_btn.Hide()
|
||||
self.unblock_btn.Hide()
|
||||
self.Layout()
|
||||
return
|
||||
|
||||
viewer_state = self.profile_data.get("viewer", {})
|
||||
is_following = bool(viewer_state.get("following"))
|
||||
is_muted = bool(viewer_state.get("muted"))
|
||||
# 'blocking' in viewer state is the URI of *our* block record, if we are blocking them.
|
||||
is_blocking_them = bool(viewer_state.get("blocking"))
|
||||
# 'blockedBy' means *they* are blocking us. If true, most actions might fail or be hidden.
|
||||
is_blocked_by_them = bool(viewer_state.get("blockedBy"))
|
||||
|
||||
if is_blocked_by_them: # If they block us, we can't do much.
|
||||
self.follow_btn.Hide()
|
||||
self.unfollow_btn.Hide()
|
||||
self.mute_btn.Hide()
|
||||
self.unmute_btn.Hide()
|
||||
# We can still block them, or unblock them if we previously did.
|
||||
self.block_btn.Show(not is_blocking_them)
|
||||
self.unblock_btn.Show(is_blocking_them)
|
||||
self.Layout()
|
||||
return
|
||||
|
||||
self.follow_btn.Show(not is_following and not is_blocking_them)
|
||||
self.unfollow_btn.Show(is_following and not is_blocking_them)
|
||||
|
||||
self.mute_btn.Show(not is_muted and not is_blocking_them)
|
||||
self.unmute_btn.Show(is_muted and not is_blocking_them)
|
||||
|
||||
self.block_btn.Show(not is_blocking_them) # Show block if we are not currently blocking them (even if they block us)
|
||||
self.unblock_btn.Show(is_blocking_them) # Show unblock if we are currently blocking them
|
||||
|
||||
self.Layout() # Refresh sizer to show/hide buttons correctly
|
||||
|
||||
|
||||
def on_user_action(self, event, command: str):
|
||||
if not self.target_user_did: # Should be set by load_profile_data
|
||||
wx.MessageBox(_("User identifier (DID) not available for this action."), _("Error"), wx.OK | wx.ICON_ERROR)
|
||||
return
|
||||
|
||||
# Confirmation for sensitive actions
|
||||
confirmation_map = {
|
||||
"unfollow_user": _("Are you sure you want to unfollow @{handle}?").format(handle=self.profile_data.get("handle","this user")),
|
||||
"block_user": _("Are you sure you want to block @{handle}? This will prevent them from interacting with you and hide their content.").format(handle=self.profile_data.get("handle","this user")),
|
||||
# Unblock usually doesn't need confirmation, but can be added if desired.
|
||||
}
|
||||
if command in confirmation_map:
|
||||
dlg = wx.MessageDialog(self, confirmation_map[command], _("Confirm Action"), wx.YES_NO | wx.ICON_QUESTION)
|
||||
if dlg.ShowModal() != wx.ID_YES:
|
||||
dlg.Destroy()
|
||||
return
|
||||
dlg.Destroy()
|
||||
|
||||
wx.BeginBusyCursor()
|
||||
self.SetStatusText(_("Performing action: {action}...").format(action=command))
|
||||
action_button = event.GetEventObject()
|
||||
if action_button:
|
||||
action_button.Disable()
|
||||
|
||||
try:
|
||||
ok = False
|
||||
if command == "follow_user" and hasattr(self.session, "follow_user"):
|
||||
ok = self.session.follow_user(self.target_user_did)
|
||||
elif command == "unfollow_user" and hasattr(self.session, "unfollow_user"):
|
||||
viewer_state = self.profile_data.get("viewer", {}) if self.profile_data else {}
|
||||
follow_uri = viewer_state.get("following")
|
||||
if follow_uri:
|
||||
ok = self.session.unfollow_user(follow_uri)
|
||||
else:
|
||||
raise RuntimeError(_("Follow information not available."))
|
||||
elif command == "mute_user" and hasattr(self.session, "mute_user"):
|
||||
ok = self.session.mute_user(self.target_user_did)
|
||||
elif command == "unmute_user" and hasattr(self.session, "unmute_user"):
|
||||
ok = self.session.unmute_user(self.target_user_did)
|
||||
elif command == "block_user" and hasattr(self.session, "block_user"):
|
||||
ok = self.session.block_user(self.target_user_did)
|
||||
elif command == "unblock_user" and hasattr(self.session, "unblock_user"):
|
||||
viewer_state = self.profile_data.get("viewer", {}) if self.profile_data else {}
|
||||
block_uri = viewer_state.get("blocking")
|
||||
if not block_uri:
|
||||
raise RuntimeError(_("Block information not available."))
|
||||
ok = self.session.unblock_user(block_uri)
|
||||
else:
|
||||
raise RuntimeError(_("This action is not supported yet."))
|
||||
|
||||
if not ok:
|
||||
raise RuntimeError(_("Action failed."))
|
||||
|
||||
wx.EndBusyCursor()
|
||||
wx.MessageBox(_("Action completed."), _("Success"), wx.OK | wx.ICON_INFORMATION, self)
|
||||
# Reload profile data in a new thread
|
||||
Thread(target=self.load_profile_data, daemon=True).start()
|
||||
except Exception as e:
|
||||
wx.EndBusyCursor()
|
||||
if action_button:
|
||||
action_button.Enable()
|
||||
self.SetStatusText(_("Action failed."))
|
||||
wx.MessageBox(str(e), _("Error"), wx.OK | wx.ICON_ERROR, self)
|
||||
|
||||
def _get_own_did(self):
|
||||
if isinstance(self.session.db, dict):
|
||||
did = self.session.db.get("user_id")
|
||||
if did:
|
||||
return did
|
||||
try:
|
||||
api = self.session._ensure_client()
|
||||
if getattr(api, "me", None):
|
||||
return api.me.did
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
def _format_profile_data(self, profile_model):
|
||||
def g(obj, key, default=None):
|
||||
if isinstance(obj, dict):
|
||||
return obj.get(key, default)
|
||||
return getattr(obj, key, default)
|
||||
|
||||
def get_count(*keys):
|
||||
for k in keys:
|
||||
val = g(profile_model, k)
|
||||
if val is not None:
|
||||
return val
|
||||
return None
|
||||
|
||||
return {
|
||||
"did": g(profile_model, "did"),
|
||||
"handle": g(profile_model, "handle"),
|
||||
"displayName": g(profile_model, "displayName") or g(profile_model, "display_name") or g(profile_model, "handle"),
|
||||
"description": g(profile_model, "description"),
|
||||
"avatar": g(profile_model, "avatar"),
|
||||
"banner": g(profile_model, "banner"),
|
||||
"followersCount": get_count("followersCount", "followers_count"),
|
||||
"followsCount": get_count("followsCount", "follows_count", "followingCount", "following_count"),
|
||||
"postsCount": get_count("postsCount", "posts_count"),
|
||||
"viewer": g(profile_model, "viewer") or {},
|
||||
}
|
||||
|
||||
def SetStatusText(self, text): # Simple status text for dialog title
|
||||
self.SetTitle(f"{_('User Profile')} - {text}")
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import wx
|
||||
|
||||
|
||||
class UserActionsDialog(wx.Dialog):
|
||||
def __init__(self, users=None, default="follow", *args, **kwargs):
|
||||
super(UserActionsDialog, self).__init__(parent=None, *args, **kwargs)
|
||||
users = users or []
|
||||
panel = wx.Panel(self)
|
||||
self.SetTitle(_(u"Action"))
|
||||
|
||||
userSizer = wx.BoxSizer()
|
||||
userLabel = wx.StaticText(panel, -1, _(u"&User"))
|
||||
default_user = users[0] if users else ""
|
||||
self.cb = wx.ComboBox(panel, -1, choices=users, value=default_user)
|
||||
self.cb.SetFocus()
|
||||
self.autocompletion = wx.Button(panel, -1, _(u"&Autocomplete users"))
|
||||
userSizer.Add(userLabel, 0, wx.ALL, 5)
|
||||
userSizer.Add(self.cb, 0, wx.ALL, 5)
|
||||
userSizer.Add(self.autocompletion, 0, wx.ALL, 5)
|
||||
|
||||
actionSizer = wx.BoxSizer(wx.VERTICAL)
|
||||
label2 = wx.StaticText(panel, -1, _(u"Action"))
|
||||
self.follow = wx.RadioButton(panel, -1, _(u"&Follow"), name=_(u"Action"), style=wx.RB_GROUP)
|
||||
self.unfollow = wx.RadioButton(panel, -1, _(u"U&nfollow"))
|
||||
self.mute = wx.RadioButton(panel, -1, _(u"&Mute"))
|
||||
self.unmute = wx.RadioButton(panel, -1, _(u"Unmu&te"))
|
||||
self.block = wx.RadioButton(panel, -1, _(u"&Block"))
|
||||
self.unblock = wx.RadioButton(panel, -1, _(u"Unbl&ock"))
|
||||
self.setup_default(default)
|
||||
|
||||
hSizer = wx.BoxSizer(wx.HORIZONTAL)
|
||||
hSizer.Add(label2, 0, wx.ALL, 5)
|
||||
actionSizer.Add(self.follow, 0, wx.ALL, 5)
|
||||
actionSizer.Add(self.unfollow, 0, wx.ALL, 5)
|
||||
actionSizer.Add(self.mute, 0, wx.ALL, 5)
|
||||
actionSizer.Add(self.unmute, 0, wx.ALL, 5)
|
||||
actionSizer.Add(self.block, 0, wx.ALL, 5)
|
||||
actionSizer.Add(self.unblock, 0, wx.ALL, 5)
|
||||
hSizer.Add(actionSizer, 0, wx.ALL, 5)
|
||||
|
||||
sizer = wx.BoxSizer(wx.VERTICAL)
|
||||
ok = wx.Button(panel, wx.ID_OK, _(u"&OK"))
|
||||
ok.SetDefault()
|
||||
cancel = wx.Button(panel, wx.ID_CANCEL, _(u"&Close"))
|
||||
btnsizer = wx.BoxSizer()
|
||||
btnsizer.Add(ok)
|
||||
btnsizer.Add(cancel)
|
||||
sizer.Add(userSizer)
|
||||
sizer.Add(hSizer, 0, wx.ALL, 5)
|
||||
sizer.Add(btnsizer)
|
||||
panel.SetSizer(sizer)
|
||||
|
||||
def get_action(self):
|
||||
if self.follow.GetValue() == True:
|
||||
return "follow"
|
||||
elif self.unfollow.GetValue() == True:
|
||||
return "unfollow"
|
||||
elif self.mute.GetValue() == True:
|
||||
return "mute"
|
||||
elif self.unmute.GetValue() == True:
|
||||
return "unmute"
|
||||
elif self.block.GetValue() == True:
|
||||
return "block"
|
||||
elif self.unblock.GetValue() == True:
|
||||
return "unblock"
|
||||
|
||||
def setup_default(self, default):
|
||||
if default == "follow":
|
||||
self.follow.SetValue(True)
|
||||
elif default == "unfollow":
|
||||
self.unfollow.SetValue(True)
|
||||
elif default == "mute":
|
||||
self.mute.SetValue(True)
|
||||
elif default == "unmute":
|
||||
self.unmute.SetValue(True)
|
||||
elif default == "block":
|
||||
self.block.SetValue(True)
|
||||
elif default == "unblock":
|
||||
self.unblock.SetValue(True)
|
||||
|
||||
def get_response(self):
|
||||
return self.ShowModal()
|
||||
|
||||
def get_user(self):
|
||||
return self.cb.GetValue()
|
||||
|
||||
def get_position(self):
|
||||
return self.cb.GetPosition()
|
||||
|
||||
def popup_menu(self, menu):
|
||||
self.PopupMenu(menu, self.cb.GetPosition())
|
||||
|
||||
@@ -0,0 +1,394 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import wx
|
||||
import logging
|
||||
from pubsub import pub
|
||||
from multiplatform_widgets import widgets # Assuming this provides generic widgets
|
||||
from approve.translation import translate as _ # For Approve's _ shortcut
|
||||
from approve.notifications import NotificationError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Supported languages for posts (ISO 639-1 codes) - can be expanded
|
||||
# This might ideally come from the session or a global config
|
||||
SUPPORTED_LANG_CHOICES = {
|
||||
_("English"): "en",
|
||||
_("Spanish"): "es",
|
||||
_("French"): "fr",
|
||||
_("German"): "de",
|
||||
_("Japanese"): "ja",
|
||||
_("Portuguese"): "pt",
|
||||
_("Russian"): "ru",
|
||||
_("Chinese"): "zh",
|
||||
# Add more as needed
|
||||
}
|
||||
|
||||
class ComposeDialog(wx.Dialog):
|
||||
def __init__(self, parent, session, reply_to_uri: str | None = None, quote_uri: str | None = None, initial_text: str = ""):
|
||||
super(ComposeDialog, self).__init__(parent, title=_("Compose Post"), style=wx.DEFAULT_DIALOG_STYLE | wx.RESIZE_BORDER)
|
||||
|
||||
self.session = session
|
||||
self.panel_config = self.session.compose_panel.get_panel_configuration()
|
||||
self.reply_to_uri = reply_to_uri
|
||||
self.initial_quote_uri = quote_uri # Store initial quote URI
|
||||
self.current_quote_uri = quote_uri # Mutable quote URI
|
||||
self.attached_files_info = [] # List of dicts: {"path": str, "alt_text": str}
|
||||
|
||||
self._init_ui(initial_text)
|
||||
self.SetMinSize((550, 450)) # Increased min size
|
||||
self.CentreOnParent()
|
||||
|
||||
def _init_ui(self, initial_text: str):
|
||||
panel = wx.Panel(self)
|
||||
main_sizer = wx.BoxSizer(wx.VERTICAL)
|
||||
|
||||
# Reply Info (if applicable)
|
||||
if self.reply_to_uri:
|
||||
# In a real app, fetch & show post snippet or author
|
||||
reply_info_label = wx.StaticText(panel, label=_("Replying to: {uri_placeholder}").format(uri_placeholder=self.reply_to_uri[-10:]))
|
||||
reply_info_label.SetToolTip(self.reply_to_uri)
|
||||
main_sizer.Add(reply_info_label, 0, wx.EXPAND | wx.LEFT | wx.RIGHT | wx.TOP, 5)
|
||||
|
||||
# Text Area
|
||||
self.text_ctrl = wx.TextCtrl(panel, style=wx.TE_MULTILINE | wx.TE_RICH2 | wx.HSCROLL)
|
||||
self.text_ctrl.SetValue(initial_text)
|
||||
self.text_ctrl.Bind(wx.EVT_TEXT, self.on_text_changed)
|
||||
main_sizer.Add(self.text_ctrl, 1, wx.EXPAND | wx.ALL, 5)
|
||||
|
||||
# Character Counter
|
||||
self.max_chars = self.panel_config.get("max_chars", 0)
|
||||
self.char_count_label = wx.StaticText(panel, label=f"0 / {self.max_chars if self.max_chars > 0 else 'N/A'}")
|
||||
main_sizer.Add(self.char_count_label, 0, wx.ALIGN_RIGHT | wx.RIGHT | wx.BOTTOM, 5)
|
||||
self.on_text_changed(None)
|
||||
|
||||
# Attachments Area
|
||||
self.max_media_attachments = self.panel_config.get("max_media_attachments", 0)
|
||||
if self.max_media_attachments > 0:
|
||||
attachment_sizer = wx.StaticBoxSizer(wx.VERTICAL, panel, _("Media Attachments") + f" (Max: {self.max_media_attachments})")
|
||||
self.attachment_list = wx.ListBox(attachment_sizer.GetStaticBox(), style=wx.LB_SINGLE, size=(-1, 60)) # Fixed height for listbox
|
||||
attachment_sizer.Add(self.attachment_list, 1, wx.EXPAND | wx.ALL, 5)
|
||||
|
||||
attach_btn_sizer = wx.BoxSizer(wx.HORIZONTAL)
|
||||
self.add_attachment_btn = wx.Button(attachment_sizer.GetStaticBox(), label=_("Add Media..."))
|
||||
self.add_attachment_btn.Bind(wx.EVT_BUTTON, self.on_add_attachment)
|
||||
attach_btn_sizer.Add(self.add_attachment_btn, 0, wx.ALL, 2)
|
||||
|
||||
self.remove_attachment_btn = wx.Button(attachment_sizer.GetStaticBox(), label=_("Remove Selected"))
|
||||
self.remove_attachment_btn.Bind(wx.EVT_BUTTON, self.on_remove_attachment)
|
||||
self.remove_attachment_btn.Enable(False)
|
||||
self.attachment_list.Bind(wx.EVT_LISTBOX, lambda evt: self.remove_attachment_btn.Enable(self.attachment_list.GetSelection() != wx.NOT_FOUND))
|
||||
attach_btn_sizer.Add(self.remove_attachment_btn, 0, wx.ALL, 2)
|
||||
attachment_sizer.Add(attach_btn_sizer, 0, wx.ALIGN_LEFT)
|
||||
main_sizer.Add(attachment_sizer, 0, wx.EXPAND | wx.ALL, 5)
|
||||
|
||||
# Quoting Area
|
||||
if self.panel_config.get("supports_quoting", False):
|
||||
quote_box_sizer = wx.StaticBoxSizer(wx.VERTICAL, panel, _("Quoting Post"))
|
||||
quote_display_sizer = wx.BoxSizer(wx.HORIZONTAL)
|
||||
self.quote_uri_text_display = wx.TextCtrl(quote_box_sizer.GetStaticBox(), value=self.current_quote_uri or _("None"), style=wx.TE_READONLY | wx.BORDER_NONE)
|
||||
self.quote_uri_text_display.SetBackgroundColour(panel.GetBackgroundColour())
|
||||
quote_display_sizer.Add(wx.StaticText(quote_box_sizer.GetStaticBox(), label=_("Quoting URI: ")), 0, wx.ALIGN_CENTER_VERTICAL | wx.ALL, 2)
|
||||
quote_display_sizer.Add(self.quote_uri_text_display, 1, wx.ALIGN_CENTER_VERTICAL | wx.ALL, 2)
|
||||
quote_box_sizer.Add(quote_display_sizer, 0, wx.EXPAND | wx.LEFT | wx.RIGHT, 2)
|
||||
|
||||
quote_btn_sizer = wx.BoxSizer(wx.HORIZONTAL)
|
||||
self.add_quote_btn = wx.Button(quote_box_sizer.GetStaticBox(), label=_("Set/Change Quote..."))
|
||||
self.add_quote_btn.Bind(wx.EVT_BUTTON, self.on_add_quote)
|
||||
quote_btn_sizer.Add(self.add_quote_btn, 0, wx.ALL, 2)
|
||||
|
||||
self.remove_quote_btn = wx.Button(quote_box_sizer.GetStaticBox(), label=_("Remove Quote"))
|
||||
self.remove_quote_btn.Bind(wx.EVT_BUTTON, self.on_remove_quote)
|
||||
self.remove_quote_btn.Enable(bool(self.current_quote_uri))
|
||||
quote_btn_sizer.Add(self.remove_quote_btn, 0, wx.ALL, 2)
|
||||
quote_box_sizer.Add(quote_btn_sizer, 0, wx.ALIGN_LEFT)
|
||||
main_sizer.Add(quote_box_sizer, 0, wx.EXPAND | wx.ALL, 5)
|
||||
|
||||
# Options (Content Warning, Language)
|
||||
options_box = wx.StaticBoxSizer(wx.VERTICAL, panel, _("Options"))
|
||||
options_grid_sizer = wx.FlexGridSizer(cols=2, vgap=5, hgap=5)
|
||||
options_grid_sizer.AddGrowableCol(1, 1)
|
||||
|
||||
if self.panel_config.get("supports_content_warning", False):
|
||||
self.sensitive_checkbox = wx.CheckBox(options_box.GetStaticBox(), label=_("Sensitive content (CW)"))
|
||||
self.sensitive_checkbox.Bind(wx.EVT_CHECKBOX, self.on_sensitive_changed)
|
||||
options_grid_sizer.Add(self.sensitive_checkbox, 0, wx.ALIGN_CENTER_VERTICAL | wx.ALL, 2)
|
||||
|
||||
self.spoiler_text_ctrl = wx.TextCtrl(options_box.GetStaticBox())
|
||||
self.spoiler_text_ctrl.SetHint(_("Content warning text (optional)"))
|
||||
self.spoiler_text_ctrl.Enable(False)
|
||||
options_grid_sizer.Add(self.spoiler_text_ctrl, 1, wx.EXPAND | wx.ALIGN_CENTER_VERTICAL | wx.ALL, 2)
|
||||
|
||||
if self.panel_config.get("supports_language_selection", False):
|
||||
lang_label = wx.StaticText(options_box.GetStaticBox(), label=_("Languages:"))
|
||||
options_grid_sizer.Add(lang_label, 0, wx.ALIGN_CENTER_VERTICAL | wx.ALL, 2)
|
||||
|
||||
self.max_langs = self.panel_config.get("max_languages", 1)
|
||||
self.lang_choices_map = SUPPORTED_LANG_CHOICES # Using global for now
|
||||
lang_display_names = list(self.lang_choices_map.keys())
|
||||
|
||||
if self.max_langs == 1: # Single choice
|
||||
choices = [_("Automatic")] + lang_display_names
|
||||
self.lang_choice_ctrl = wx.Choice(options_box.GetStaticBox(), choices=choices)
|
||||
self.lang_choice_ctrl.SetSelection(0) # Default to Automatic/None
|
||||
else: # Multiple choices
|
||||
self.lang_choice_ctrl = wx.CheckListBox(options_box.GetStaticBox(), choices=lang_display_names, size=(-1, 70))
|
||||
self.lang_choice_ctrl.Bind(wx.EVT_CHECKLISTBOX, self.on_lang_checklist_changed)
|
||||
options_grid_sizer.Add(self.lang_choice_ctrl, 1, wx.EXPAND | wx.ALIGN_CENTER_VERTICAL | wx.ALL, 2)
|
||||
|
||||
if options_grid_sizer.GetChildren():
|
||||
options_box.Add(options_grid_sizer, 1, wx.EXPAND | wx.ALL, 0) # No border for grid sizer itself
|
||||
main_sizer.Add(options_box, 0, wx.EXPAND | wx.ALL, 5)
|
||||
|
||||
# Buttons (Send, Cancel)
|
||||
btn_sizer = wx.StdDialogButtonSizer()
|
||||
self.send_btn = wx.Button(panel, wx.ID_OK, _("Send"))
|
||||
self.send_btn.SetDefault()
|
||||
self.send_btn.Bind(wx.EVT_BUTTON, self.on_send)
|
||||
btn_sizer.AddButton(self.send_btn)
|
||||
|
||||
cancel_btn = wx.Button(panel, wx.ID_CANCEL, _("Cancel"))
|
||||
btn_sizer.AddButton(cancel_btn)
|
||||
btn_sizer.Realize()
|
||||
main_sizer.Add(btn_sizer, 0, wx.ALIGN_CENTER | wx.ALL, 5)
|
||||
|
||||
panel.SetSizer(main_sizer)
|
||||
self.Fit()
|
||||
|
||||
|
||||
def on_text_changed(self, event):
|
||||
text_length = len(self.text_ctrl.GetValue())
|
||||
self.char_count_label.SetLabel(f"{text_length} / {self.max_chars}")
|
||||
if self.max_chars > 0 and text_length > self.max_chars:
|
||||
self.char_count_label.SetForegroundColour(wx.RED)
|
||||
else:
|
||||
self.char_count_label.SetForegroundColour(wx.BLACK) # System default
|
||||
|
||||
def on_add_attachment(self, event):
|
||||
max_attachments = self.panel_config.get("max_media_attachments", 0)
|
||||
if len(self.attached_files_info) >= self.max_media_attachments:
|
||||
wx.MessageBox(_("Maximum number of attachments ({max}) reached.").format(max=self.max_media_attachments), _("Attachment Limit"), wx.OK | wx.ICON_INFORMATION)
|
||||
return
|
||||
|
||||
supported_mimes = self.panel_config.get("supported_media_types", [])
|
||||
wildcard_parts = []
|
||||
if not supported_mimes: # Default if none specified by session
|
||||
wildcard_parts.append("All files (*.*)|*.*")
|
||||
else:
|
||||
for mime_type in supported_mimes:
|
||||
# Example: "image/jpeg" -> "JPEG files (*.jpg;*.jpeg)|*.jpg;*.jpeg"
|
||||
name = mime_type.split('/')[0].capitalize() + " " + mime_type.split('/')[1].upper()
|
||||
if mime_type == "image/jpeg": exts = "*.jpg;*.jpeg"
|
||||
elif mime_type == "image/png": exts = "*.png"
|
||||
elif mime_type == "image/gif": exts = "*.gif" # If supported
|
||||
else: exts = "*." + mime_type.split('/')[-1]
|
||||
wildcard_parts.append(f"{name} ({exts})|{exts}")
|
||||
|
||||
wildcard = "|".join(wildcard_parts) if wildcard_parts else wx.FileSelectorDefaultWildcardStr
|
||||
|
||||
dialog = wx.FileDialog(self, _("Select Media File"), wildcard=wildcard, style=wx.FD_OPEN | wx.FD_FILE_MUST_EXIST)
|
||||
if dialog.ShowModal() == wx.ID_OK:
|
||||
path = dialog.GetPath()
|
||||
alt_text = ""
|
||||
if self.panel_config.get("supports_alternative_text", False) and \
|
||||
any(pt in path.lower() for pt in ['.jpg', '.jpeg', '.png']): # crude check for image
|
||||
alt_text_dialog = wx.TextEntryDialog(self, _("Enter accessibility description (alt text) for the image:"), _("Image Description"))
|
||||
if alt_text_dialog.ShowModal() == wx.ID_OK:
|
||||
alt_text = alt_text_dialog.GetValue()
|
||||
alt_text_dialog.Destroy()
|
||||
|
||||
self.attached_files_info.append({"path": path, "alt_text": alt_text})
|
||||
self.attachment_list.Append(os.path.basename(path) + (f" ({_('Alt:')} {alt_text})" if alt_text else ""))
|
||||
dialog.Destroy()
|
||||
|
||||
def on_remove_attachment(self, event):
|
||||
selected_index = self.attachment_list.GetSelection()
|
||||
if selected_index != wx.NOT_FOUND:
|
||||
self.attachment_list.Delete(selected_index)
|
||||
del self.attached_files_info[selected_index]
|
||||
|
||||
def on_add_quote(self, event):
|
||||
dialog = wx.TextEntryDialog(self, _("Enter the AT-URI of the Bluesky post to quote:"), _("Quote Post"), self.current_quote_uri or "")
|
||||
if dialog.ShowModal() == wx.ID_OK:
|
||||
self.current_quote_uri = dialog.GetValue().strip()
|
||||
self.quote_uri_text_display.SetValue(self.current_quote_uri or _("None"))
|
||||
self.remove_quote_btn.Enable(bool(self.current_quote_uri))
|
||||
dialog.Destroy()
|
||||
|
||||
def on_remove_quote(self, event):
|
||||
self.current_quote_uri = None
|
||||
self.quote_uri_text_display.SetValue(_("None"))
|
||||
self.remove_quote_btn.Enable(False)
|
||||
|
||||
|
||||
def on_sensitive_changed(self, event):
|
||||
if hasattr(self, 'spoiler_text_ctrl'):
|
||||
self.spoiler_text_ctrl.Enable(event.IsChecked())
|
||||
if event.IsChecked():
|
||||
self.spoiler_text_ctrl.SetFocus()
|
||||
|
||||
def on_lang_checklist_changed(self, event):
|
||||
"""Ensure no more than max_languages are selected for CheckListBox."""
|
||||
if isinstance(self.lang_choice_ctrl, wx.CheckListBox):
|
||||
checked_indices = self.lang_choice_ctrl.GetCheckedItems()
|
||||
if len(checked_indices) > self.max_langs:
|
||||
# Find the item that was just checked to cause the overflow
|
||||
# This is a bit tricky as EVT_CHECKLISTBOX triggers after the change.
|
||||
# A simpler approach is to inform the user and let them uncheck.
|
||||
wx.MessageBox(
|
||||
_("You can select a maximum of {num} languages.").format(num=self.max_langs),
|
||||
_("Language Selection Limit"), wx.OK | wx.ICON_EXCLAMATION
|
||||
)
|
||||
# Optionally, uncheck the last checked item if possible to determine
|
||||
# For now, just warn. User has to manually correct.
|
||||
|
||||
|
||||
def on_send(self, event): # Renamed from async on_send
|
||||
text_content = self.text_ctrl.GetValue()
|
||||
if not text_content.strip() and not self.attached_files_info and not self.current_quote_uri:
|
||||
wx.MessageBox(_("Cannot send an empty post."), _("Error"), wx.OK | wx.ICON_ERROR)
|
||||
return
|
||||
|
||||
# Language processing
|
||||
langs = []
|
||||
if hasattr(self, 'lang_choice_ctrl'):
|
||||
if isinstance(self.lang_choice_ctrl, wx.Choice):
|
||||
sel_idx = self.lang_choice_ctrl.GetSelection()
|
||||
if sel_idx > 0: # Index 0 is empty/no selection
|
||||
lang_display_name = self.lang_choice_ctrl.GetString(sel_idx)
|
||||
langs.append(self.lang_choices_map[lang_display_name])
|
||||
elif isinstance(self.lang_choice_ctrl, wx.CheckListBox):
|
||||
checked_indices = self.lang_choice_ctrl.GetCheckedItems()
|
||||
if len(checked_indices) > self.max_langs:
|
||||
wx.MessageBox(_("Please select no more than {num} languages.").format(num=self.max_langs), _("Language Error"), wx.OK | wx.ICON_ERROR)
|
||||
return
|
||||
for idx in checked_indices:
|
||||
lang_display_name = self.lang_choice_ctrl.GetString(idx)
|
||||
langs.append(self.lang_choices_map[lang_display_name])
|
||||
|
||||
# Files and Alt Texts
|
||||
files_to_send = [f_info["path"] for f_info in self.attached_files_info]
|
||||
alt_texts_to_send = [f_info["alt_text"] for f_info in self.attached_files_info]
|
||||
|
||||
# Content Warning
|
||||
cw_text = None
|
||||
is_sensitive_flag = False
|
||||
if hasattr(self, 'sensitive_checkbox') and self.sensitive_checkbox.IsChecked():
|
||||
is_sensitive_flag = True
|
||||
if hasattr(self, 'spoiler_text_ctrl'):
|
||||
cw_text = self.spoiler_text_ctrl.GetValue().strip() or None # Use None if empty for Bluesky
|
||||
|
||||
kwargs_for_send = {
|
||||
"quote_uri": self.current_quote_uri,
|
||||
"langs": langs if langs else None,
|
||||
"media_alt_texts": alt_texts_to_send if alt_texts_to_send else None,
|
||||
# "tags" could be extracted from text server-side or client-side (not implemented here)
|
||||
}
|
||||
|
||||
# Filter out None values from kwargs to avoid sending them if not set
|
||||
kwargs_for_send = {k: v for k, v in kwargs_for_send.items() if v is not None}
|
||||
|
||||
try:
|
||||
self.send_btn.Disable()
|
||||
# This is an async call, so it should be handled appropriately in wxPython
|
||||
# For simplicity in this step, assuming it's handled by the caller or a wrapper
|
||||
# In a real wxPython app, this would involve asyncio.create_task and wx.CallAfter
|
||||
# or running the send in a separate thread and using wx.CallAfter for UI updates.
|
||||
# For now, we'll make this method async and let the caller handle it.
|
||||
|
||||
# wx.BeginBusyCursor() # Indicate work
|
||||
# Using pubsub to decouple UI from direct async call to session
|
||||
pub.sendMessage(
|
||||
"compose_dialog.send_post",
|
||||
session=self.session,
|
||||
text=text_content,
|
||||
files=files_to_send if files_to_send else None,
|
||||
reply_to=self.reply_to_uri,
|
||||
cw_text=cw_text,
|
||||
is_sensitive=is_sensitive_flag,
|
||||
kwargs=kwargs_for_send
|
||||
)
|
||||
# Success will be signaled by another pubsub message if needed, or just close.
|
||||
# self.EndModal(wx.ID_OK) # Moved to controller after successful send via pubsub
|
||||
|
||||
except NotificationError as e:
|
||||
wx.MessageBox(str(e), _("Post Error"), wx.OK | wx.ICON_ERROR)
|
||||
except Exception as e:
|
||||
logger.error("Error sending post from compose dialog: %s", e, exc_info=True)
|
||||
wx.MessageBox(_("An unexpected error occurred: {error}").format(error=str(e)), _("Error"), wx.OK | wx.ICON_ERROR)
|
||||
finally:
|
||||
# wx.EndBusyCursor()
|
||||
if not self.IsBeingDeleted(): # Ensure dialog still exists
|
||||
self.send_btn.Enable()
|
||||
# Do not automatically close here; let the controller do it on success signal.
|
||||
# self.EndModal(wx.ID_OK) # if successful and no further UI feedback needed in dialog
|
||||
|
||||
def get_data(self):
|
||||
"""Helper to get all data, though on_send handles it directly."""
|
||||
# This method isn't strictly necessary if on_send does all the work,
|
||||
# but can be useful for other patterns.
|
||||
pass
|
||||
|
||||
if __name__ == '__main__':
|
||||
# Example usage (requires a mock session and panel_config)
|
||||
app = wx.App(False)
|
||||
|
||||
class MockComposePanel:
|
||||
def get_panel_configuration(self):
|
||||
return {
|
||||
"max_chars": 300,
|
||||
"max_media_attachments": 4,
|
||||
"supported_media_types": ["image/jpeg", "image/png"],
|
||||
"supports_alternative_text": True,
|
||||
"supports_content_warning": True,
|
||||
"supports_language_selection": True,
|
||||
"max_languages": 3,
|
||||
"supports_quoting": True,
|
||||
}
|
||||
|
||||
class MockSession:
|
||||
def __init__(self):
|
||||
self.compose_panel = MockComposePanel()
|
||||
self.uid = "mock_user" # Needed by some base methods if called
|
||||
|
||||
async def send_message(self, message, files=None, reply_to=None, cw_text=None, is_sensitive=False, **kwargs):
|
||||
print("MockSession.send_message called:")
|
||||
print(f" Text: {message}")
|
||||
print(f" Files: {files}")
|
||||
print(f" Reply To: {reply_to}")
|
||||
print(f" CW: {cw_text}, Sensitive: {is_sensitive}")
|
||||
print(f" kwargs: {kwargs}")
|
||||
# Simulate success or failure
|
||||
# raise NotificationError("This is a mock send error!")
|
||||
return "at://did:plc:mockposturi/app.bsky.feed.post/mockrkey"
|
||||
|
||||
# Pubsub listener for the send_post event (simulates what mainController would do)
|
||||
def on_actual_send(session, text, files, reply_to, cw_text, is_sensitive, kwargs):
|
||||
print("Pubsub: compose_dialog.send_post received. Calling session.send_message...")
|
||||
async def do_send():
|
||||
try:
|
||||
uri = await session.send_message(
|
||||
message=text,
|
||||
files=files,
|
||||
reply_to=reply_to,
|
||||
cw_text=cw_text,
|
||||
is_sensitive=is_sensitive,
|
||||
**kwargs
|
||||
)
|
||||
print(f"Pubsub: Send successful, URI: {uri}")
|
||||
# In real app, would call dialog.EndModal(wx.ID_OK) via wx.CallAfter
|
||||
wx.CallAfter(dialog.EndModal, wx.ID_OK)
|
||||
except Exception as e:
|
||||
print(f"Pubsub: Send failed: {e}")
|
||||
# In real app, show error and re-enable send button in dialog via wx.CallAfter
|
||||
wx.CallAfter(wx.MessageBox, str(e), "Error", wx.OK | wx.ICON_ERROR, dialog)
|
||||
wx.CallAfter(dialog.send_btn.Enable, True)
|
||||
|
||||
asyncio.create_task(do_send())
|
||||
|
||||
pub.subscribe(on_actual_send, "compose_dialog.send_post")
|
||||
|
||||
session = MockSession()
|
||||
# Example: dialog = ComposeDialog(None, session, reply_to_uri="at://reply_uri", quote_uri="at://quote_uri", initial_text="Hello")
|
||||
dialog = ComposeDialog(None, session, initial_text="Hello Bluesky!")
|
||||
dialog.ShowModal()
|
||||
dialog.Destroy()
|
||||
app.MainLoop()
|
||||
@@ -26,8 +26,15 @@ class UserListDialog(wx.Dialog):
|
||||
buttons_sizer.Add(self.actions_button, 0, wx.RIGHT, 10)
|
||||
self.details_button = wx.Button(panel, wx.ID_ANY, _("&View profile"))
|
||||
buttons_sizer.Add(self.details_button, 0, wx.RIGHT, 10)
|
||||
self.load_more_button = wx.Button(panel, wx.ID_ANY, _("&Load more"))
|
||||
self.load_more_button.Hide()
|
||||
buttons_sizer.Add(self.load_more_button, 0, wx.RIGHT, 10)
|
||||
close_button = wx.Button(panel, wx.ID_CANCEL, "&Close")
|
||||
buttons_sizer.Add(close_button, 0)
|
||||
main_sizer.Add(buttons_sizer, 0, wx.ALIGN_CENTER | wx.BOTTOM, 15)
|
||||
panel.SetSizer(main_sizer)
|
||||
# self.SetSizerAndFit(main_sizer)
|
||||
|
||||
def add_users(self, users):
|
||||
for user in users:
|
||||
self.user_list.Append(user)
|
||||
|
||||
Reference in New Issue
Block a user