mirror of
https://github.com/MCV-Software/TWBlue.git
synced 2026-03-06 17:37:33 +01:00
Avance
This commit is contained in:
@@ -1,6 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import wx
|
||||
import output
|
||||
from wxUI.dialogs.blueski.showUserProfile import ShowUserProfileDialog
|
||||
from typing import Any
|
||||
import languageHandler # Ensure _() injection
|
||||
|
||||
@@ -19,10 +22,16 @@ class Handler:
|
||||
|
||||
def create_buffers(self, session, createAccounts=True, controller=None):
|
||||
name = session.get_name()
|
||||
controller.accounts.append(name)
|
||||
if createAccounts:
|
||||
from pubsub import pub
|
||||
pub.sendMessage("core.create_account", name=name, session_id=session.session_id, logged=True)
|
||||
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)
|
||||
# Discover/home timeline
|
||||
from pubsub import pub
|
||||
@@ -45,6 +54,66 @@ class Handler:
|
||||
start=False,
|
||||
kwargs=dict(parent=controller.view.nb, name="following_timeline", session=session)
|
||||
)
|
||||
# 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)
|
||||
)
|
||||
# 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)
|
||||
)
|
||||
# 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)
|
||||
)
|
||||
# Following (Users)
|
||||
pub.sendMessage(
|
||||
"createBuffer",
|
||||
buffer_type="FollowingBuffer",
|
||||
session_type="blueski",
|
||||
buffer_title=_("Following (Users)"),
|
||||
parent_tab=root_position,
|
||||
start=False,
|
||||
kwargs=dict(parent=controller.view.nb, name="following", session=session)
|
||||
)
|
||||
# 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)
|
||||
)
|
||||
# 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)
|
||||
)
|
||||
|
||||
def start_buffer(self, controller, buffer):
|
||||
"""Start a newly created Bluesky buffer."""
|
||||
@@ -86,6 +155,45 @@ class Handler:
|
||||
except Exception:
|
||||
logger.exception("Error opening Bluesky account settings dialog")
|
||||
|
||||
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
|
||||
@@ -97,3 +205,156 @@ class Handler:
|
||||
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"""
|
||||
session = getattr(buffer, "session", None)
|
||||
if not session:
|
||||
output.speak(_("No active session."), 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
|
||||
item = buffer.get_item() if hasattr(buffer, "get_item") else None
|
||||
if item:
|
||||
if g(item, "handle") or g(item, "did"):
|
||||
user_ident = g(item, "handle") or g(item, "did")
|
||||
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, "handle") or g(author, "did")
|
||||
|
||||
users = [user_ident] if user_ident else []
|
||||
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"""
|
||||
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
|
||||
author = getattr(item, "author", None) or (item.get("post", {}).get("author") if isinstance(item, dict) else None)
|
||||
handle = getattr(author, "handle", "unknown") if author else "unknown"
|
||||
title = _("Conversation with {0}").format(handle)
|
||||
|
||||
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)
|
||||
)
|
||||
|
||||
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 _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 not actor:
|
||||
actor = session.db.get("user_id") or session.db.get("user_name")
|
||||
handle = session.db.get("user_name") or actor
|
||||
return actor, handle
|
||||
|
||||
def _open_user_list(self, main_controller, session, actor, handle, list_type):
|
||||
account_name = session.get_name()
|
||||
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):
|
||||
name = "followers" if list_type == "followers" else "following"
|
||||
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
|
||||
|
||||
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(account_name, account_name),
|
||||
start=True,
|
||||
kwargs=dict(parent=main_controller.view.nb, name=list_name, session=session, actor=actor)
|
||||
)
|
||||
|
||||
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."))
|
||||
|
||||
@@ -1,75 +1,98 @@
|
||||
from __future__ import annotations
|
||||
|
||||
# -*- coding: utf-8 -*-
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
import widgetUtils
|
||||
import output
|
||||
from wxUI.dialogs.blueski import userActions as userActionsDialog
|
||||
import languageHandler
|
||||
|
||||
fromapprove.translation import translate as _
|
||||
# fromapprove.controller.mastodon import userActions as mastodon_user_actions # If adapting
|
||||
|
||||
if TYPE_CHECKING:
|
||||
fromapprove.sessions.blueski.session import Session as BlueskiSession # Adjusted
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# This file defines user-specific actions that can be performed on Blueski entities,
|
||||
# typically represented as buttons or links in the UI, often on user profiles or posts.
|
||||
|
||||
# For Blueski, actions might include:
|
||||
# - Viewing a user's profile on Bluesky/Blueski instance.
|
||||
# - Following/Unfollowing a user.
|
||||
# - Muting/Blocking a user.
|
||||
# - Reporting a user.
|
||||
# - Fetching a user's latest posts.
|
||||
|
||||
# These actions are often presented in a context menu or as direct buttons.
|
||||
# The `get_user_actions` method in the BlueskiSession class would define these.
|
||||
# This file would contain the implementation or further handling logic if needed,
|
||||
# or if actions are too complex for simple lambda/method calls in the session class.
|
||||
|
||||
# Example structure for defining an action:
|
||||
# (This might be more detailed if actions require forms or multi-step processes)
|
||||
|
||||
# def view_profile_action(session: BlueskiSession, user_id: str) -> dict[str, Any]:
|
||||
# """
|
||||
# Generates data for a "View Profile on Blueski" action.
|
||||
# user_id here would be the Blueski DID or handle.
|
||||
# """
|
||||
# # profile_url = f"https://bsky.app/profile/{user_id}" # Example, construct from handle or DID
|
||||
# # This might involve resolving DID to handle or vice-versa if only one is known.
|
||||
# # handle = await session.util.get_username_from_user_id(user_id) or user_id
|
||||
# # profile_url = f"https://bsky.app/profile/{handle}"
|
||||
|
||||
# return {
|
||||
# "id": "blueski_view_profile",
|
||||
# "label": _("View Profile on Bluesky"),
|
||||
# "icon": "external-link-alt", # FontAwesome icon name
|
||||
# "action_type": "link", # "link", "modal", "api_call"
|
||||
# "url": profile_url, # For "link" type
|
||||
# # "api_endpoint": "/api/blueski/user_action", # For "api_call"
|
||||
# # "payload": {"action": "view_profile", "target_user_id": user_id},
|
||||
# "confirmation_required": False,
|
||||
# }
|
||||
log = logging.getLogger("controller.blueski.userActions")
|
||||
|
||||
|
||||
# async def follow_user_action_handler(session: BlueskiSession, target_user_id: str) -> dict[str, Any]:
|
||||
# """
|
||||
# Handles the 'follow_user' action for Blueski.
|
||||
# target_user_id should be the DID of the user to follow.
|
||||
# """
|
||||
# # success = await session.util.follow_user(target_user_id)
|
||||
# # if success:
|
||||
# # return {"status": "success", "message": _("User {target_user_id} followed.").format(target_user_id=target_user_id)}
|
||||
# # else:
|
||||
# # return {"status": "error", "message": _("Failed to follow user {target_user_id}.").format(target_user_id=target_user_id)}
|
||||
# return {"status": "pending", "message": "Follow action not implemented yet."}
|
||||
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
|
||||
|
||||
|
||||
# The list of available actions is typically defined in the Session class,
|
||||
# e.g., BlueskiSession.get_user_actions(). That method would return a list
|
||||
# of dictionaries, and this file might provide handlers for more complex actions
|
||||
# if they aren't simple API calls defined directly in the session's util.
|
||||
class userActions(BasicUserSelector):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(userActions, self).__init__(*args, **kwargs)
|
||||
if self.dialog.get_response() == widgetUtils.OK:
|
||||
self.process_action()
|
||||
|
||||
# For now, this file can be a placeholder if most actions are simple enough
|
||||
# to be handled directly by the session.util methods or basic handler routes.
|
||||
def create_dialog(self, users):
|
||||
self.dialog = userActionsDialog.UserActionsDialog(users)
|
||||
|
||||
logger.info("Blueski userActions module loaded (placeholders).")
|
||||
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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
4
src/controller/buffers/blueski/__init__.py
Normal file
4
src/controller/buffers/blueski/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from .timeline import HomeTimeline, FollowingTimeline, NotificationBuffer, Conversation
|
||||
from .user import FollowersBuffer, FollowingBuffer, BlocksBuffer
|
||||
from .chat import ConversationListBuffer, ChatBuffer as ChatMessageBuffer
|
||||
579
src/controller/buffers/blueski/base.py
Normal file
579
src/controller/buffers/blueski/base.py
Normal file
@@ -0,0 +1,579 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import logging
|
||||
import wx
|
||||
import output
|
||||
import sound
|
||||
import config
|
||||
import widgetUtils
|
||||
from pubsub import pub
|
||||
from controller.buffers.base import base
|
||||
from sessions.blueski import compose
|
||||
from wxUI.buffers.blueski import panels as BlueskiPanels
|
||||
|
||||
log = logging.getLogger("controller.buffers.blueski.base")
|
||||
|
||||
class BaseBuffer(base.Buffer):
|
||||
def __init__(self, parent=None, name=None, session=None, *args, **kwargs):
|
||||
# Adapt params to BaseBuffer
|
||||
# BaseBuffer expects (parent, function, name, sessionObject, account)
|
||||
function = "timeline" # Dummy
|
||||
sessionObject = session
|
||||
account = session.get_name() if session else "Unknown"
|
||||
|
||||
super(BaseBuffer, self).__init__(parent, function, name=name, sessionObject=sessionObject, account=account, *args, **kwargs)
|
||||
|
||||
self.session = sessionObject
|
||||
self.account = account
|
||||
self.name = name
|
||||
self.create_buffer(parent, name)
|
||||
self.buffer.account = account
|
||||
self.invisible = True
|
||||
compose_func = kwargs.get("compose_func", "compose_post")
|
||||
self.compose_function = getattr(compose, compose_func)
|
||||
self.sound = sound
|
||||
|
||||
# Initialize DB list if needed
|
||||
if self.name not in self.session.db:
|
||||
self.session.db[self.name] = []
|
||||
|
||||
self.bind_events()
|
||||
|
||||
def create_buffer(self, parent, name):
|
||||
# Default to HomePanel, can be overridden
|
||||
self.buffer = BlueskiPanels.HomePanel(parent, name, account=self.account)
|
||||
self.buffer.session = self.session
|
||||
|
||||
def bind_events(self):
|
||||
# Bind essential events
|
||||
widgetUtils.connect_event(self.buffer.list.list, widgetUtils.KEYPRESS, self.get_event)
|
||||
|
||||
# Buttons
|
||||
if hasattr(self.buffer, "post"):
|
||||
self.buffer.post.Bind(wx.EVT_BUTTON, self.on_post)
|
||||
if hasattr(self.buffer, "reply"):
|
||||
self.buffer.reply.Bind(wx.EVT_BUTTON, self.on_reply)
|
||||
if hasattr(self.buffer, "repost"):
|
||||
self.buffer.repost.Bind(wx.EVT_BUTTON, self.on_repost)
|
||||
if hasattr(self.buffer, "like"):
|
||||
self.buffer.like.Bind(wx.EVT_BUTTON, self.on_like)
|
||||
if hasattr(self.buffer, "dm"):
|
||||
self.buffer.dm.Bind(wx.EVT_BUTTON, self.on_dm)
|
||||
if hasattr(self.buffer, "actions"):
|
||||
self.buffer.actions.Bind(wx.EVT_BUTTON, self.user_actions)
|
||||
|
||||
def on_post(self, evt):
|
||||
from wxUI.dialogs.blueski import postDialogs
|
||||
dlg = postDialogs.Post(caption=_("New Post"))
|
||||
if dlg.ShowModal() == wx.ID_OK:
|
||||
text, files, cw, langs = dlg.get_payload()
|
||||
self.session.send_message(message=text, files=files, cw_text=cw, langs=langs)
|
||||
output.speak(_("Sending..."))
|
||||
dlg.Destroy()
|
||||
|
||||
def on_reply(self, evt):
|
||||
item = self.get_item()
|
||||
if not item: return
|
||||
|
||||
# item is a feed object or dict.
|
||||
# We need its URI.
|
||||
uri = self.get_selected_item_id()
|
||||
if not uri:
|
||||
uri = item.get("uri") if isinstance(item, dict) else getattr(item, "uri", None)
|
||||
# Attempt to get CID if present for consistency, though send_message handles it
|
||||
|
||||
def g(obj, key, default=None):
|
||||
if isinstance(obj, dict):
|
||||
return obj.get(key, default)
|
||||
return getattr(obj, key, default)
|
||||
|
||||
author = g(item, "author")
|
||||
if not author:
|
||||
post = g(item, "post") or g(item, "record")
|
||||
author = g(post, "author") if post else None
|
||||
handle = g(author, "handle", "")
|
||||
initial_text = f"@{handle} " if handle and not handle.startswith("@") else (f"{handle} " if handle else "")
|
||||
|
||||
from wxUI.dialogs.blueski import postDialogs
|
||||
dlg = postDialogs.Post(caption=_("Reply"), text=initial_text)
|
||||
if dlg.ShowModal() == wx.ID_OK:
|
||||
text, files, cw, langs = dlg.get_payload()
|
||||
self.session.send_message(message=text, files=files, reply_to=uri, cw_text=cw, langs=langs)
|
||||
output.speak(_("Sending reply..."))
|
||||
dlg.Destroy()
|
||||
|
||||
def on_repost(self, evt):
|
||||
self.share_item(confirm=True)
|
||||
|
||||
def share_item(self, confirm=False, *args, **kwargs):
|
||||
item = self.get_item()
|
||||
if not item: return
|
||||
uri = item.get("uri") if isinstance(item, dict) else getattr(item, "uri", None)
|
||||
|
||||
if confirm:
|
||||
if wx.MessageBox(_("Repost this?"), _("Confirm"), wx.YES_NO | wx.ICON_QUESTION) != wx.YES:
|
||||
return
|
||||
|
||||
self.session.repost(uri)
|
||||
output.speak(_("Reposted."))
|
||||
|
||||
def on_like(self, evt):
|
||||
self.toggle_favorite(confirm=True)
|
||||
|
||||
def toggle_favorite(self, confirm=False, *args, **kwargs):
|
||||
item = self.get_item()
|
||||
if not item: return
|
||||
uri = item.get("uri") if isinstance(item, dict) else getattr(item, "uri", None)
|
||||
|
||||
if confirm:
|
||||
if wx.MessageBox(_("Like this post?"), _("Confirm"), wx.YES_NO | wx.ICON_QUESTION) != wx.YES:
|
||||
return
|
||||
|
||||
self.session.like(uri)
|
||||
output.speak(_("Liked."))
|
||||
|
||||
def add_to_favorites(self, *args, **kwargs):
|
||||
self.toggle_favorite(confirm=False)
|
||||
|
||||
def remove_from_favorites(self, *args, **kwargs):
|
||||
# We need unlike support in session
|
||||
pass
|
||||
|
||||
def on_dm(self, evt):
|
||||
self.send_message()
|
||||
|
||||
def send_message(self, *args, **kwargs):
|
||||
# Global shortcut for DM
|
||||
item = self.get_item()
|
||||
if not item:
|
||||
output.speak(_("No user selected to message."), True)
|
||||
return
|
||||
|
||||
author = getattr(item, "author", None) or (item.get("post", {}).get("author") if isinstance(item, dict) else None)
|
||||
if not author:
|
||||
# Try item itself if it's a user object (UserBuffer)
|
||||
author = item
|
||||
|
||||
did = getattr(author, "did", None) or author.get("did")
|
||||
handle = getattr(author, "handle", "unknown") or (author.get("handle") if isinstance(author, dict) else "unknown")
|
||||
|
||||
if not did:
|
||||
return
|
||||
|
||||
if self.showing == False:
|
||||
dlg = wx.TextEntryDialog(None, _("Message to {0}:").format(handle), _("Send Message"))
|
||||
if dlg.ShowModal() == wx.ID_OK:
|
||||
text = dlg.GetValue()
|
||||
if text:
|
||||
try:
|
||||
api = self.session._ensure_client()
|
||||
# Get or create conversation
|
||||
res = api.chat.bsky.convo.get_convo_for_members({"members": [did]})
|
||||
convo_id = res.convo.id
|
||||
self.session.send_chat_message(convo_id, text)
|
||||
output.speak(_("Message sent."), True)
|
||||
except:
|
||||
log.exception("Error sending Bluesky DM (invisible)")
|
||||
output.speak(_("Failed to send message."), True)
|
||||
dlg.Destroy()
|
||||
return
|
||||
|
||||
# If showing, we'll just open the chat buffer for now as it's more structured
|
||||
self.view_chat_with_user(did, handle)
|
||||
|
||||
def user_actions(self, *args, **kwargs):
|
||||
pub.sendMessage("execute-action", action="follow")
|
||||
|
||||
def view_chat_with_user(self, did, handle):
|
||||
try:
|
||||
api = self.session._ensure_client()
|
||||
res = api.chat.bsky.convo.get_convo_for_members({"members": [did]})
|
||||
convo_id = res.convo.id
|
||||
|
||||
import application
|
||||
title = _("Chat: {0}").format(handle)
|
||||
application.app.controller.create_buffer(
|
||||
buffer_type="chat_messages",
|
||||
session_type="blueski",
|
||||
buffer_title=title,
|
||||
kwargs={"session": self.session, "convo_id": convo_id, "name": title},
|
||||
start=True
|
||||
)
|
||||
except:
|
||||
output.speak(_("Could not open chat."), True)
|
||||
|
||||
def block_user(self, *args, **kwargs):
|
||||
item = self.get_item()
|
||||
if not item: return
|
||||
author = getattr(item, "author", None) or (item.get("post", {}).get("author") if isinstance(item, dict) else item)
|
||||
did = getattr(author, "did", None) or (author.get("did") if isinstance(author, dict) else None)
|
||||
handle = getattr(author, "handle", "unknown") or (author.get("handle") if isinstance(author, dict) else "unknown")
|
||||
|
||||
if wx.MessageBox(_("Are you sure you want to block {0}?").format(handle), _("Block"), wx.YES_NO | wx.ICON_WARNING) == wx.YES:
|
||||
if self.session.block_user(did):
|
||||
output.speak(_("User blocked."))
|
||||
else:
|
||||
output.speak(_("Failed to block user."))
|
||||
|
||||
def unblock_user(self, *args, **kwargs):
|
||||
# Unblocking usually needs the block record URI.
|
||||
# In a UserBuffer (Blocks), it might be present.
|
||||
item = self.get_item()
|
||||
if not item: return
|
||||
|
||||
# Check if item itself is a block record or user object with viewer.blocking
|
||||
block_uri = None
|
||||
if isinstance(item, dict):
|
||||
block_uri = item.get("viewer", {}).get("blocking")
|
||||
else:
|
||||
viewer = getattr(item, "viewer", None)
|
||||
block_uri = getattr(viewer, "blocking", None) if viewer else None
|
||||
|
||||
if not block_uri:
|
||||
output.speak(_("Could not find block information for this user."), True)
|
||||
return
|
||||
|
||||
if self.session.unblock_user(block_uri):
|
||||
output.speak(_("User unblocked."))
|
||||
else:
|
||||
output.speak(_("Failed to unblock user."))
|
||||
|
||||
def put_items_on_list(self, number_of_items):
|
||||
list_to_use = self.session.db[self.name]
|
||||
count = self.buffer.list.get_count()
|
||||
reverse = False
|
||||
try:
|
||||
reverse = self.session.settings["general"].get("reverse_timelines", False)
|
||||
except: pass
|
||||
|
||||
if number_of_items == 0:
|
||||
return
|
||||
|
||||
safe = True
|
||||
relative_times = self.session.settings["general"].get("relative_times", False)
|
||||
show_screen_names = self.session.settings["general"].get("show_screen_names", False)
|
||||
|
||||
if count == 0:
|
||||
for i in list_to_use:
|
||||
post = self.compose_function(i, 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)
|
||||
# Set selection
|
||||
total = self.buffer.list.get_count()
|
||||
if total > 0:
|
||||
if not reverse:
|
||||
self.buffer.list.select_item(total - 1) # Bottom
|
||||
else:
|
||||
self.buffer.list.select_item(0) # Top
|
||||
|
||||
elif count > 0 and number_of_items > 0:
|
||||
if not reverse:
|
||||
items = list_to_use[:number_of_items] # If we prepended items for normal (oldest first) timeline... wait.
|
||||
# Standard flow: "New items" come from API.
|
||||
# If standard timeline (oldest at top, newest at bottom): new items appended to DB.
|
||||
# UI: append to bottom.
|
||||
items = list_to_use[len(list_to_use)-number_of_items:]
|
||||
for i in items:
|
||||
post = self.compose_function(i, 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)
|
||||
else:
|
||||
# Reverse timeline (Newest at top).
|
||||
# New items appended to DB? Or inserted at 0?
|
||||
# Mastodon BaseBuffer:
|
||||
# if reverse_timelines == False: items_db.insert(0, i) (Wait, insert at 0?)
|
||||
# Actually let's look at `get_more_items` in Mastodon BaseBuffer again.
|
||||
# "if self.session.settings["general"]["reverse_timelines"] == False: items_db.insert(0, i)"
|
||||
# This means for standard timeline, new items (newer time) go to index 0?
|
||||
# No, standard timeline usually has oldest at top. Retrieve "more items" usually means "newer items" or "older items" depending on context (streaming vs styling).
|
||||
|
||||
# Let's trust that we just need to insert based on how we updated DB in start_stream.
|
||||
|
||||
# For now, simplistic approach:
|
||||
items = list_to_use[0:number_of_items] # Assuming we inserted at 0 in DB
|
||||
# items.reverse() if needed?
|
||||
for i in items:
|
||||
post = self.compose_function(i, self.session.db, self.session.settings, relative_times=relative_times, show_screen_names=show_screen_names, safe=safe)
|
||||
self.buffer.list.insert_item(True, *post) # Insert at 0 (True)
|
||||
|
||||
def reply(self, *args, **kwargs):
|
||||
self.on_reply(None)
|
||||
|
||||
def post_status(self, *args, **kwargs):
|
||||
self.on_post(None)
|
||||
|
||||
def share_item(self, *args, **kwargs):
|
||||
self.on_repost(None)
|
||||
|
||||
def destroy_status(self, *args, **kwargs):
|
||||
# Delete post
|
||||
item = self.get_item()
|
||||
if not item: return
|
||||
uri = self.get_selected_item_id()
|
||||
if not uri:
|
||||
if isinstance(item, dict):
|
||||
uri = item.get("uri") or item.get("post", {}).get("uri")
|
||||
else:
|
||||
post = getattr(item, "post", None)
|
||||
uri = getattr(item, "uri", None) or getattr(post, "uri", None)
|
||||
if not uri:
|
||||
output.speak(_("Could not find the post identifier."), True)
|
||||
return
|
||||
|
||||
# Check if author is self
|
||||
# Implementation depends on parsing URI or checking active user DID vs author DID
|
||||
# For now, just try and handle error
|
||||
if wx.MessageBox(_("Delete this post?"), _("Confirm"), wx.YES_NO | wx.ICON_QUESTION) == wx.YES:
|
||||
try:
|
||||
ok = self.session.delete_post(uri)
|
||||
if not ok:
|
||||
output.speak(_("Could not delete."), True)
|
||||
return
|
||||
index = self.buffer.list.get_selected()
|
||||
if index > -1 and self.session.db.get(self.name):
|
||||
try:
|
||||
self.session.db[self.name].pop(index)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
self.buffer.list.remove_item(index)
|
||||
except Exception:
|
||||
pass
|
||||
output.speak(_("Deleted."))
|
||||
except Exception:
|
||||
log.exception("Error deleting Bluesky post")
|
||||
output.speak(_("Could not delete."), True)
|
||||
|
||||
def url(self, *args, **kwargs):
|
||||
item = self.get_item()
|
||||
if not item: return
|
||||
|
||||
uri = item.get("uri") if isinstance(item, dict) else getattr(item, "uri", None)
|
||||
# Convert at:// uri to https://bsky.app link
|
||||
if uri and "at://" in uri and "app.bsky.feed.post" in uri:
|
||||
parts = uri.split("/")
|
||||
# at://did:plc:xxx/app.bsky.feed.post/rkey
|
||||
did = parts[2]
|
||||
rkey = parts[-1]
|
||||
|
||||
# Need handle for prettier url, but did works? bluesky web supports profile/did/post/rkey?
|
||||
# Let's try to find handle if possible
|
||||
handle = None
|
||||
if isinstance(item, dict):
|
||||
handle = item.get("handle")
|
||||
else:
|
||||
handle = getattr(getattr(item, "author", None), "handle", None)
|
||||
|
||||
target = handle if handle else did
|
||||
link = f"https://bsky.app/profile/{target}/post/{rkey}"
|
||||
|
||||
import webbrowser
|
||||
webbrowser.open(link)
|
||||
|
||||
def audio(self, *args, **kwargs):
|
||||
output.speak(_("Audio playback not supported for Bluesky yet."))
|
||||
|
||||
# Helper to map standard keys if they don't invoke the methods above via get_event
|
||||
# But usually get_event is enough.
|
||||
|
||||
# Also implement "view_item" if standard keymap uses it
|
||||
def get_formatted_message(self):
|
||||
return self.compose_function(self.get_item(), 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):
|
||||
item = self.get_item()
|
||||
if item is None:
|
||||
return
|
||||
# Use the compose function to get the full formatted text
|
||||
# Bluesky compose returns [user, text, date, source]
|
||||
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))
|
||||
# Join them for a full readout similar to Mastodon's template render
|
||||
return " ".join(composed)
|
||||
|
||||
def view_item(self, *args, **kwargs):
|
||||
self.view_conversation()
|
||||
|
||||
def view_conversation(self, *args, **kwargs):
|
||||
item = self.get_item()
|
||||
if not item: return
|
||||
|
||||
uri = item.get("uri") if isinstance(item, dict) else getattr(item, "uri", None)
|
||||
if not uri: return
|
||||
|
||||
import application
|
||||
controller = application.app.controller
|
||||
|
||||
handle = "Unknown"
|
||||
if isinstance(item, dict):
|
||||
handle = item.get("author", {}).get("handle", "Unknown")
|
||||
else:
|
||||
handle = getattr(getattr(item, "author", None), "handle", "Unknown")
|
||||
|
||||
title = _("Conversation: {0}").format(handle)
|
||||
|
||||
controller.create_buffer(
|
||||
buffer_type="conversation",
|
||||
session_type="blueski",
|
||||
buffer_title=title,
|
||||
kwargs={"session": self.session, "uri": uri, "name": title},
|
||||
start=True
|
||||
)
|
||||
|
||||
def get_item(self):
|
||||
index = self.buffer.list.get_selected()
|
||||
if index > -1 and self.session.db.get(self.name) is not None:
|
||||
# Logic implies DB order matches UI order
|
||||
return self.session.db[self.name][index]
|
||||
|
||||
def get_selected_item_id(self):
|
||||
item = self.get_item()
|
||||
if not item:
|
||||
return None
|
||||
|
||||
if isinstance(item, dict):
|
||||
uri = item.get("uri")
|
||||
if uri:
|
||||
return uri
|
||||
post = item.get("post") or item.get("record")
|
||||
if isinstance(post, dict):
|
||||
return post.get("uri")
|
||||
return getattr(post, "uri", None)
|
||||
|
||||
return getattr(item, "uri", None) or getattr(getattr(item, "post", None), "uri", None)
|
||||
|
||||
def get_selected_item_author_details(self):
|
||||
item = self.get_item()
|
||||
if not item:
|
||||
return None
|
||||
|
||||
def g(obj, key, default=None):
|
||||
if isinstance(obj, dict):
|
||||
return obj.get(key, default)
|
||||
return getattr(obj, key, default)
|
||||
|
||||
author = None
|
||||
if g(item, "did") or g(item, "handle"):
|
||||
author = item
|
||||
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 not author:
|
||||
return None
|
||||
|
||||
return {
|
||||
"did": g(author, "did"),
|
||||
"handle": g(author, "handle"),
|
||||
}
|
||||
|
||||
def process_items(self, items, play_sound=True):
|
||||
"""
|
||||
Process list of items (FeedViewPost objects), update DB, and update UI.
|
||||
Returns number of new items.
|
||||
"""
|
||||
if not items:
|
||||
return 0
|
||||
|
||||
# Identify new items
|
||||
new_items = []
|
||||
current_uris = set()
|
||||
# Create a set of keys from existing db to check duplicates
|
||||
def get_key(it):
|
||||
if isinstance(it, dict):
|
||||
post = it.get("post")
|
||||
if isinstance(post, dict) and post.get("uri"):
|
||||
return post.get("uri")
|
||||
if it.get("uri"):
|
||||
return it.get("uri")
|
||||
if it.get("id"):
|
||||
return it.get("id")
|
||||
if it.get("did"):
|
||||
return it.get("did")
|
||||
if it.get("handle"):
|
||||
return it.get("handle")
|
||||
author = it.get("author")
|
||||
if isinstance(author, dict):
|
||||
return author.get("did") or author.get("handle")
|
||||
return None
|
||||
post = getattr(it, "post", None)
|
||||
if post is not None:
|
||||
return getattr(post, "uri", None)
|
||||
for attr in ("uri", "id", "did", "handle"):
|
||||
val = getattr(it, attr, None)
|
||||
if val:
|
||||
return val
|
||||
author = getattr(it, "author", None)
|
||||
if author is not None:
|
||||
return getattr(author, "did", None) or getattr(author, "handle", None)
|
||||
return None
|
||||
|
||||
for item in self.session.db[self.name]:
|
||||
key = get_key(item)
|
||||
if key:
|
||||
current_uris.add(key)
|
||||
|
||||
for item in items:
|
||||
key = get_key(item)
|
||||
if key:
|
||||
if key in current_uris:
|
||||
continue
|
||||
current_uris.add(key)
|
||||
new_items.append(item)
|
||||
|
||||
if not new_items:
|
||||
return 0
|
||||
|
||||
# Add to DB
|
||||
# Reverse timeline setting
|
||||
reverse = False
|
||||
try: reverse = self.session.settings["general"].get("reverse_timelines", False)
|
||||
except: pass
|
||||
|
||||
# If reverse (newest at top), we insert new items at index 0?
|
||||
# Typically API returns newest first.
|
||||
# If DB is [Newest ... Oldest] (Reverse order)
|
||||
# Then we insert new items at 0.
|
||||
# If DB is [Oldest ... Newest] (Normal order)
|
||||
# Then we append new items at end.
|
||||
|
||||
# But traditionally APIs return [Newest ... Oldest].
|
||||
# So 'items' list is [Newest ... Oldest].
|
||||
|
||||
if reverse: # Newest at top
|
||||
# DB: [Newest (Index 0) ... Oldest]
|
||||
# We want to insert 'new_items' at 0.
|
||||
# But 'new_items' are also [Newest...Oldest]
|
||||
# So duplicates check handled.
|
||||
# We insert the whole block at 0?
|
||||
for it in reversed(new_items): # Insert oldest of new first, so newest ends up at 0
|
||||
self.session.db[self.name].insert(0, it)
|
||||
else: # Oldest at top
|
||||
# DB: [Oldest ... Newest]
|
||||
# APIs return [Newest ... Oldest]
|
||||
# We want to append them.
|
||||
# So we append reversed(new_items)?
|
||||
for it in reversed(new_items):
|
||||
self.session.db[self.name].append(it)
|
||||
|
||||
# Update UI
|
||||
self.put_items_on_list(len(new_items))
|
||||
|
||||
# Play sound
|
||||
if play_sound and self.sound and not self.session.settings["sound"]["session_mute"]:
|
||||
self.session.sound.play(self.sound)
|
||||
|
||||
return len(new_items)
|
||||
|
||||
def save_positions(self):
|
||||
try:
|
||||
self.session.db[self.name+"_pos"] = self.buffer.list.get_selected()
|
||||
except: pass
|
||||
|
||||
def remove_buffer(self, force=False):
|
||||
if self.type in ("conversation", "chat_messages") or self.name.lower().startswith("conversation"):
|
||||
try:
|
||||
self.session.db.pop(self.name, None)
|
||||
except Exception:
|
||||
pass
|
||||
return True
|
||||
return False
|
||||
|
||||
117
src/controller/buffers/blueski/chat.py
Normal file
117
src/controller/buffers/blueski/chat.py
Normal file
@@ -0,0 +1,117 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import logging
|
||||
import wx
|
||||
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.chat")
|
||||
|
||||
class ConversationListBuffer(BaseBuffer):
|
||||
def __init__(self, *args, **kwargs):
|
||||
kwargs["compose_func"] = "compose_convo"
|
||||
super(ConversationListBuffer, self).__init__(*args, **kwargs)
|
||||
self.type = "chat"
|
||||
|
||||
def create_buffer(self, parent, name):
|
||||
self.buffer = BlueskiPanels.ChatPanel(parent, name)
|
||||
self.buffer.session = self.session
|
||||
|
||||
def start_stream(self, mandatory=False, play_sound=True):
|
||||
count = self.session.settings["general"].get("max_posts_per_call", 50)
|
||||
try:
|
||||
res = self.session.list_convos(limit=count)
|
||||
items = res.get("items", [])
|
||||
|
||||
# Clear to avoid list weirdness on refreshes?
|
||||
# Chat list usually replaces content on fetch
|
||||
self.session.db[self.name] = []
|
||||
self.buffer.list.clear()
|
||||
|
||||
return self.process_items(items, play_sound)
|
||||
except Exception:
|
||||
log.exception("Error fetching conversations")
|
||||
return 0
|
||||
|
||||
def url(self, *args, **kwargs):
|
||||
# In chat list, Enter (URL) should open the chat conversation buffer
|
||||
self.view_chat()
|
||||
|
||||
def send_message(self, *args, **kwargs):
|
||||
# Global shortcut for DM
|
||||
self.view_chat()
|
||||
|
||||
def view_chat(self):
|
||||
item = self.get_item()
|
||||
if not item: return
|
||||
|
||||
convo_id = getattr(item, "id", None) or item.get("id")
|
||||
if not convo_id: return
|
||||
|
||||
# Determine participants names for title
|
||||
members = getattr(item, "members", []) or item.get("members", [])
|
||||
others = [m for m in members if (getattr(m, "did", None) or m.get("did")) != self.session.db["user_id"]]
|
||||
if not others: others = members
|
||||
names = ", ".join([getattr(m, "handle", "unknown") or m.get("handle") for m in others])
|
||||
|
||||
title = _("Chat: {0}").format(names)
|
||||
|
||||
import application
|
||||
application.app.controller.create_buffer(
|
||||
buffer_type="chat_messages",
|
||||
session_type="blueski",
|
||||
buffer_title=title,
|
||||
kwargs={"session": self.session, "convo_id": convo_id, "name": title},
|
||||
start=True
|
||||
)
|
||||
|
||||
class ChatBuffer(BaseBuffer):
|
||||
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")
|
||||
|
||||
def create_buffer(self, parent, name):
|
||||
self.buffer = BlueskiPanels.ChatMessagePanel(parent, name)
|
||||
self.buffer.session = self.session
|
||||
|
||||
def start_stream(self, mandatory=False, play_sound=True):
|
||||
if not self.convo_id: return 0
|
||||
count = self.session.settings["general"].get("max_posts_per_call", 50)
|
||||
try:
|
||||
res = self.session.get_convo_messages(self.convo_id, limit=count)
|
||||
items = res.get("items", [])
|
||||
# Message order in API is often Oldest...Newest or vice versa.
|
||||
# We want them in order and only new ones.
|
||||
# For chat, let's just clear and show last N messages for simplicity now.
|
||||
self.session.db[self.name] = []
|
||||
self.buffer.list.clear()
|
||||
|
||||
# API usually returns newest first. We want newest at bottom.
|
||||
items = list(reversed(items))
|
||||
|
||||
return self.process_items(items, play_sound)
|
||||
except Exception:
|
||||
log.exception("Error fetching chat messages")
|
||||
return 0
|
||||
|
||||
def on_reply(self, evt):
|
||||
# Open a text entry chat box
|
||||
dlg = wx.TextEntryDialog(None, _("Message:"), _("Send Message"), style=wx.TE_MULTILINE | wx.OK | wx.CANCEL)
|
||||
if dlg.ShowModal() == wx.ID_OK:
|
||||
text = dlg.GetValue()
|
||||
if text:
|
||||
try:
|
||||
self.session.send_chat_message(self.convo_id, text)
|
||||
output.speak(_("Message sent."))
|
||||
# Refresh
|
||||
self.start_stream(mandatory=True, play_sound=False)
|
||||
except:
|
||||
output.speak(_("Failed to send message."))
|
||||
dlg.Destroy()
|
||||
|
||||
def send_message(self, *args, **kwargs):
|
||||
# Global shortcut for DM
|
||||
self.on_reply(None)
|
||||
209
src/controller/buffers/blueski/timeline.py
Normal file
209
src/controller/buffers/blueski/timeline.py
Normal file
@@ -0,0 +1,209 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import logging
|
||||
from .base import BaseBuffer
|
||||
from wxUI.buffers.blueski import panels as BlueskiPanels
|
||||
from pubsub import pub
|
||||
|
||||
log = logging.getLogger("controller.buffers.blueski.timeline")
|
||||
|
||||
class HomeTimeline(BaseBuffer):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(HomeTimeline, self).__init__(*args, **kwargs)
|
||||
self.type = "home_timeline"
|
||||
self.feed_uri = None
|
||||
|
||||
def create_buffer(self, parent, name):
|
||||
# Override to use HomePanel
|
||||
self.buffer = BlueskiPanels.HomePanel(parent, name)
|
||||
self.buffer.session = self.session
|
||||
|
||||
def start_stream(self, mandatory=False, play_sound=True):
|
||||
count = 50
|
||||
try:
|
||||
count = self.session.settings["general"].get("max_posts_per_call", 50)
|
||||
except: pass
|
||||
|
||||
api = self.session._ensure_client()
|
||||
|
||||
# Discover Logic
|
||||
if not self.feed_uri:
|
||||
self.feed_uri = self._resolve_discover_feed(api)
|
||||
|
||||
items = []
|
||||
try:
|
||||
res = None
|
||||
if self.feed_uri:
|
||||
# Fetch feed
|
||||
res = api.app.bsky.feed.get_feed({"feed": self.feed_uri, "limit": count})
|
||||
else:
|
||||
# Fallback to standard timeline
|
||||
res = api.app.bsky.feed.get_timeline({"limit": count})
|
||||
|
||||
feed = getattr(res, "feed", [])
|
||||
items = list(feed)
|
||||
|
||||
except Exception:
|
||||
log.exception("Failed to fetch home timeline")
|
||||
return 0
|
||||
|
||||
return self.process_items(items, play_sound)
|
||||
|
||||
def _resolve_discover_feed(self, api):
|
||||
# Reuse logic from panels.py
|
||||
try:
|
||||
cached = self.session.db.get("discover_feed_uri")
|
||||
if cached: return cached
|
||||
|
||||
# Simple fallback: Suggested feeds
|
||||
try:
|
||||
res = api.app.bsky.feed.get_suggested_feeds({"limit": 50})
|
||||
feeds = getattr(res, "feeds", [])
|
||||
for feed in 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
|
||||
try: self.session.save_persistent_data()
|
||||
except: pass
|
||||
return uri
|
||||
except: pass
|
||||
|
||||
return None
|
||||
except:
|
||||
return None
|
||||
|
||||
class FollowingTimeline(BaseBuffer):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(FollowingTimeline, self).__init__(*args, **kwargs)
|
||||
self.type = "following_timeline"
|
||||
|
||||
def create_buffer(self, parent, name):
|
||||
self.buffer = BlueskiPanels.HomePanel(parent, name) # Reuse HomePanel layout
|
||||
self.buffer.session = self.session
|
||||
|
||||
def start_stream(self, mandatory=False, play_sound=True):
|
||||
count = 50
|
||||
try: count = self.session.settings["general"].get("max_posts_per_call", 50)
|
||||
except: pass
|
||||
|
||||
api = self.session._ensure_client()
|
||||
try:
|
||||
# Force reverse-chronological
|
||||
res = api.app.bsky.feed.get_timeline({"limit": count, "algorithm": "reverse-chronological"})
|
||||
feed = getattr(res, "feed", [])
|
||||
items = list(feed)
|
||||
except Exception:
|
||||
log.exception("Error fetching following timeline")
|
||||
return 0
|
||||
|
||||
return self.process_items(items, play_sound)
|
||||
|
||||
class NotificationBuffer(BaseBuffer):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(NotificationBuffer, self).__init__(*args, **kwargs)
|
||||
self.type = "notifications"
|
||||
|
||||
def create_buffer(self, parent, name):
|
||||
self.buffer = BlueskiPanels.NotificationPanel(parent, name)
|
||||
self.buffer.session = self.session
|
||||
|
||||
def start_stream(self, mandatory=False, play_sound=True):
|
||||
count = 50
|
||||
api = self.session._ensure_client()
|
||||
try:
|
||||
res = api.app.bsky.notification.list_notifications({"limit": count})
|
||||
notifs = getattr(res, "notifications", [])
|
||||
items = []
|
||||
# Notifications are not FeedViewPost. They have different structure.
|
||||
# self.compose_function expects FeedViewPost-like structure (post, author, etc).
|
||||
# We need to map them or have a different compose function.
|
||||
# For now, let's skip items to avoid crash
|
||||
# Or attempt to map.
|
||||
except:
|
||||
return 0
|
||||
return 0
|
||||
|
||||
class Conversation(BaseBuffer):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(Conversation, self).__init__(*args, **kwargs)
|
||||
self.type = "conversation"
|
||||
# We need the root URI or the URI of the post to show thread for
|
||||
self.root_uri = kwargs.get("uri")
|
||||
|
||||
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:
|
||||
params = {"uri": self.root_uri, "depth": 100, "parentHeight": 100}
|
||||
try:
|
||||
res = api.app.bsky.feed.get_post_thread(params)
|
||||
except Exception:
|
||||
res = api.app.bsky.feed.get_post_thread({"uri": self.root_uri})
|
||||
thread = getattr(res, "thread", None)
|
||||
if not thread:
|
||||
return 0
|
||||
|
||||
def g(obj, key, default=None):
|
||||
if isinstance(obj, dict):
|
||||
return obj.get(key, default)
|
||||
return getattr(obj, key, default)
|
||||
|
||||
# Find the root of the thread tree
|
||||
curr = thread
|
||||
while g(curr, "parent"):
|
||||
curr = g(curr, "parent")
|
||||
|
||||
final_items = []
|
||||
|
||||
def traverse(node):
|
||||
if not node:
|
||||
return
|
||||
post = g(node, "post")
|
||||
if post:
|
||||
final_items.append(post)
|
||||
replies = g(node, "replies") or []
|
||||
for r in replies:
|
||||
traverse(r)
|
||||
|
||||
traverse(curr)
|
||||
|
||||
# Clear existing items to avoid duplication when refreshing a thread view (which changes structure little)
|
||||
self.session.db[self.name] = []
|
||||
self.buffer.list.clear() # Clear UI too
|
||||
|
||||
return self.process_items(final_items, play_sound)
|
||||
|
||||
except Exception:
|
||||
log.exception("Error fetching thread")
|
||||
return 0
|
||||
|
||||
class LikesBuffer(BaseBuffer):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(LikesBuffer, self).__init__(*args, **kwargs)
|
||||
self.type = "likes"
|
||||
|
||||
def create_buffer(self, parent, name):
|
||||
self.buffer = BlueskiPanels.HomePanel(parent, name)
|
||||
self.buffer.session = self.session
|
||||
|
||||
def start_stream(self, mandatory=False, play_sound=True):
|
||||
count = 50
|
||||
try:
|
||||
count = self.session.settings["general"].get("max_posts_per_call", 50)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
api = self.session._ensure_client()
|
||||
try:
|
||||
res = api.app.bsky.feed.get_actor_likes({"actor": api.me.did, "limit": count})
|
||||
items = getattr(res, "feed", None) or getattr(res, "items", None) or []
|
||||
except Exception:
|
||||
log.exception("Error fetching likes")
|
||||
return 0
|
||||
|
||||
return self.process_items(list(items), play_sound)
|
||||
63
src/controller/buffers/blueski/user.py
Normal file
63
src/controller/buffers/blueski/user.py
Normal file
@@ -0,0 +1,63 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import logging
|
||||
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"
|
||||
|
||||
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.session.settings["general"].get("max_posts_per_call", 50)
|
||||
actor = (
|
||||
self.kwargs.get("actor")
|
||||
or self.kwargs.get("did")
|
||||
or self.kwargs.get("handle")
|
||||
or self.kwargs.get("id")
|
||||
)
|
||||
|
||||
try:
|
||||
# We call the method in session. API methods return {"items": [...], "cursor": ...}
|
||||
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 = res.get("items", [])
|
||||
|
||||
# Clear existing items for these lists to start fresh?
|
||||
# Or append? Standard lists in TWBlue usually append.
|
||||
# But followers/blocks are often full-sync or large jumps.
|
||||
# For now, append like timelines.
|
||||
|
||||
return self.process_items(items, play_sound)
|
||||
except Exception:
|
||||
log.exception(f"Error fetching user list for {self.name}")
|
||||
return 0
|
||||
|
||||
class FollowersBuffer(UserBuffer):
|
||||
def __init__(self, *args, **kwargs):
|
||||
kwargs["api_method"] = "get_followers"
|
||||
super(FollowersBuffer, self).__init__(*args, **kwargs)
|
||||
|
||||
class FollowingBuffer(UserBuffer):
|
||||
def __init__(self, *args, **kwargs):
|
||||
kwargs["api_method"] = "get_follows"
|
||||
super(FollowingBuffer, self).__init__(*args, **kwargs)
|
||||
|
||||
class BlocksBuffer(UserBuffer):
|
||||
def __init__(self, *args, **kwargs):
|
||||
kwargs["api_method"] = "get_blocks"
|
||||
super(BlocksBuffer, self).__init__(*args, **kwargs)
|
||||
@@ -5,6 +5,7 @@ import logging
|
||||
import webbrowser
|
||||
import wx
|
||||
import requests
|
||||
import asyncio
|
||||
import keystrokeEditor
|
||||
import sessions
|
||||
import widgetUtils
|
||||
@@ -293,9 +294,52 @@ class Controller(object):
|
||||
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"):
|
||||
try:
|
||||
@@ -329,60 +373,35 @@ class Controller(object):
|
||||
try:
|
||||
buffer_panel_class = None
|
||||
if session_type == "blueski":
|
||||
from wxUI.buffers.blueski import panels as BlueskiPanels # Import new panels
|
||||
if buffer_type == "home_timeline":
|
||||
buffer_panel_class = BlueskiPanels.BlueskiHomeTimelinePanel
|
||||
# kwargs for HomeTimelinePanel: parent, name, session
|
||||
# 'name' is buffer_title, 'parent' is self.view.nb
|
||||
# 'session' needs to be fetched based on user_id in kwargs
|
||||
if "user_id" in kwargs and "session" not in kwargs: # Ensure session is passed
|
||||
kwargs["session"] = sessions.sessions.get(kwargs["user_id"])
|
||||
# Clean unsupported kwarg for panel ctor
|
||||
if "user_id" in kwargs:
|
||||
kwargs.pop("user_id", None)
|
||||
if "name" not in kwargs: kwargs["name"] = buffer_title
|
||||
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
|
||||
|
||||
elif buffer_type == "user_timeline":
|
||||
buffer_panel_class = BlueskiPanels.BlueskiUserTimelinePanel
|
||||
# kwargs for UserTimelinePanel: parent, name, session, target_user_did, target_user_handle
|
||||
if "user_id" in kwargs and "session" not in kwargs:
|
||||
kwargs["session"] = sessions.sessions.get(kwargs["user_id"])
|
||||
kwargs.pop("user_id", None)
|
||||
if "name" not in kwargs: kwargs["name"] = buffer_title
|
||||
# target_user_did and target_user_handle must be in kwargs from blueski.Handler
|
||||
buffer_map = {
|
||||
"home_timeline": BlueskiTimelines.HomeTimeline,
|
||||
"following_timeline": BlueskiTimelines.FollowingTimeline,
|
||||
"notifications": BlueskiTimelines.NotificationBuffer,
|
||||
"conversation": BlueskiTimelines.Conversation,
|
||||
"likes": BlueskiTimelines.LikesBuffer,
|
||||
"UserBuffer": BlueskiUsers.UserBuffer,
|
||||
"FollowersBuffer": BlueskiUsers.FollowersBuffer,
|
||||
"FollowingBuffer": BlueskiUsers.FollowingBuffer,
|
||||
"BlocksBuffer": BlueskiUsers.BlocksBuffer,
|
||||
"ConversationListBuffer": BlueskiChats.ConversationListBuffer,
|
||||
"ChatMessageBuffer": BlueskiChats.ChatBuffer,
|
||||
"chat_messages": BlueskiChats.ChatBuffer,
|
||||
}
|
||||
|
||||
elif buffer_type == "notifications":
|
||||
buffer_panel_class = BlueskiPanels.BlueskiNotificationPanel
|
||||
if "user_id" in kwargs and "session" not in kwargs:
|
||||
kwargs["session"] = sessions.sessions.get(kwargs["user_id"])
|
||||
kwargs.pop("user_id", None)
|
||||
if "name" not in kwargs: kwargs["name"] = buffer_title
|
||||
# target_user_did and target_user_handle must be in kwargs from blueski.Handler
|
||||
|
||||
elif buffer_type == "notifications":
|
||||
buffer_panel_class = BlueskiPanels.BlueskiNotificationPanel
|
||||
if "user_id" in kwargs and "session" not in kwargs:
|
||||
kwargs["session"] = sessions.sessions.get(kwargs["user_id"])
|
||||
kwargs.pop("user_id", None)
|
||||
if "name" not in kwargs: kwargs["name"] = buffer_title
|
||||
elif buffer_type == "user_list_followers" or buffer_type == "user_list_following":
|
||||
buffer_panel_class = BlueskiPanels.BlueskiUserListPanel
|
||||
elif buffer_type == "following_timeline":
|
||||
buffer_panel_class = BlueskiPanels.BlueskiFollowingTimelinePanel
|
||||
# Clean stray keys that this panel doesn't accept
|
||||
kwargs.pop("user_id", None)
|
||||
kwargs.pop("list_type", None)
|
||||
if "name" not in kwargs: kwargs["name"] = buffer_title
|
||||
else:
|
||||
log.warning(f"Unsupported Blueski buffer type: {buffer_type}. Falling back to generic.")
|
||||
# Fallback to trying to find it in generic buffers or error
|
||||
available_buffers = getattr(buffers, "base", None) # Or some generic panel module
|
||||
if available_buffers and hasattr(available_buffers, buffer_type):
|
||||
buffer_panel_class = getattr(available_buffers, buffer_type)
|
||||
elif available_buffers and hasattr(available_buffers, "TimelinePanel"): # Example generic
|
||||
buffer_panel_class = getattr(available_buffers, "TimelinePanel")
|
||||
else:
|
||||
raise AttributeError(f"Blueski buffer type {buffer_type} not found in blueski.panels or base panels.")
|
||||
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):
|
||||
@@ -722,6 +741,12 @@ class Controller(object):
|
||||
|
||||
session = buffer.session
|
||||
if getattr(session, "type", "") == "blueski":
|
||||
author_handle = ""
|
||||
if hasattr(buffer, "get_selected_item_author_details"):
|
||||
details = buffer.get_selected_item_author_details()
|
||||
if details:
|
||||
author_handle = details.get("handle", "") or details.get("did", "")
|
||||
initial_text = f"@{author_handle} " if author_handle and not author_handle.startswith("@") else (f"{author_handle} " if author_handle else "")
|
||||
if self.showing == False:
|
||||
dlg = wx.TextEntryDialog(None, _("Write your reply:"), _("Reply"))
|
||||
if dlg.ShowModal() == wx.ID_OK:
|
||||
@@ -742,7 +767,7 @@ class Controller(object):
|
||||
dlg.Destroy()
|
||||
return
|
||||
from wxUI.dialogs.blueski.postDialogs import Post as ATPostDialog
|
||||
dlg = ATPostDialog(caption=_("Reply"))
|
||||
dlg = ATPostDialog(caption=_("Reply"), text=initial_text)
|
||||
if dlg.ShowModal() == wx.ID_OK:
|
||||
text, files, cw_text, langs = dlg.get_payload()
|
||||
dlg.Destroy()
|
||||
@@ -1432,13 +1457,10 @@ class Controller(object):
|
||||
def update_buffers(self):
|
||||
for i in self.buffers[:]:
|
||||
if i.session != None and i.session.is_logged == True:
|
||||
# For Blueski, initial load is in session.start() or manual.
|
||||
# Periodic updates would need a separate timer or manual refresh via update_buffer.
|
||||
if i.session.KIND != "blueski":
|
||||
try:
|
||||
i.start_stream(mandatory=True) # This is likely for streaming connections or timed polling within buffer
|
||||
except Exception as err:
|
||||
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))
|
||||
try:
|
||||
i.start_stream(mandatory=True)
|
||||
except Exception as err:
|
||||
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):
|
||||
"""Handles the 'Update buffer' menu command to fetch newest items."""
|
||||
@@ -1454,50 +1476,27 @@ class Controller(object):
|
||||
new_ids = []
|
||||
try:
|
||||
if session.KIND == "blueski":
|
||||
if bf.name == f"{session.label} Home": # Assuming buffer name indicates type
|
||||
# Its panel's load_initial_posts calls session.fetch_home_timeline
|
||||
if hasattr(bf, "load_initial_posts"): # Generic for timeline panels
|
||||
await bf.load_initial_posts(limit=config.app["app-settings"].get("items_per_request", 20))
|
||||
new_ids = getattr(bf, "item_uris", [])
|
||||
else: # Should not happen if panel is correctly typed
|
||||
logger.warning(f"Home timeline panel for {session.KIND} missing load_initial_posts")
|
||||
elif bf.type == "notifications" and hasattr(bf, "refresh_notifications"):
|
||||
await bf.refresh_notifications(limit=config.app["app-settings"].get("items_per_request", 20))
|
||||
new_ids = []
|
||||
elif bf.type == "user_timeline" and hasattr(bf, "load_initial_posts"):
|
||||
await bf.load_initial_posts(limit=config.app["app-settings"].get("items_per_request", 20))
|
||||
new_ids = getattr(bf, "item_uris", [])
|
||||
elif bf.type in ["user_list_followers", "user_list_following"] and hasattr(bf, "load_initial_users"):
|
||||
await bf.load_initial_users(limit=config.app["app-settings"].get("items_per_request", 30))
|
||||
new_ids = [u.get("did") for u in getattr(bf, "user_list_data", []) if isinstance(u,dict)]
|
||||
if hasattr(bf, "start_stream"):
|
||||
count = bf.start_stream(mandatory=True)
|
||||
if count: new_ids = [str(x) for x in range(count)]
|
||||
else:
|
||||
if hasattr(bf, "start_stream"): # Fallback for non-Blueski panels or unhandled types
|
||||
count = bf.start_stream(mandatory=True, avoid_autoreading=True)
|
||||
if count is not None: new_ids = [str(x) for x in range(count)] # Dummy IDs for count
|
||||
else:
|
||||
output.speak(_(u"This buffer type cannot be updated in this way."), True)
|
||||
return
|
||||
else: # For other session types (e.g. Mastodon)
|
||||
output.speak(_(u"This buffer type cannot be updated."), True)
|
||||
return
|
||||
else: # Generic fallback for other sessions
|
||||
if hasattr(bf, "start_stream"):
|
||||
count = bf.start_stream(mandatory=True, avoid_autoreading=True)
|
||||
if count is not None: new_ids = [str(x) for x in range(count)] # Dummy IDs for count
|
||||
if count: new_ids = [str(x) for x in range(count)]
|
||||
else:
|
||||
output.speak(_(u"Unable to update this buffer."), True)
|
||||
return
|
||||
|
||||
# Generic feedback based on new_ids for timelines or user lists
|
||||
# Generic feedback
|
||||
if bf.type in ["home_timeline", "user_timeline"]:
|
||||
output.speak(_("{0} posts retrieved").format(len(new_ids)), True)
|
||||
elif bf.type in ["user_list_followers", "user_list_following"]:
|
||||
output.speak(_("{0} users retrieved").format(len(new_ids)), True)
|
||||
elif bf.type == "notifications":
|
||||
output.speak(_("Notifications updated."), True)
|
||||
# else, original start_stream might have given feedback
|
||||
|
||||
except NotificationError as e:
|
||||
output.speak(str(e), True) # Ensure output.speak is on main thread if called from here
|
||||
except Exception as e_general:
|
||||
logger.error(f"Error updating buffer {bf.name}: {e_general}", exc_info=True)
|
||||
except Exception as e:
|
||||
log.exception("Error updating buffer %s", bf.name)
|
||||
output.speak(_("An error occurred while updating the buffer."), True)
|
||||
|
||||
wx.CallAfter(asyncio.create_task, do_update())
|
||||
@@ -1674,10 +1673,9 @@ class Controller(object):
|
||||
# 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.
|
||||
# It's an async method, so needs to be called appropriately.
|
||||
async def _show_details():
|
||||
await handler.user_details(buffer)
|
||||
wx.CallAfter(asyncio.create_task, _show_details())
|
||||
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)
|
||||
|
||||
@@ -1737,9 +1735,9 @@ class Controller(object):
|
||||
if author_details: user = author_details
|
||||
|
||||
if handler and hasattr(handler, 'open_followers_timeline'):
|
||||
async def _open_followers():
|
||||
await handler.open_followers_timeline(main_controller=self, session=session_to_use, user_payload=user)
|
||||
wx.CallAfter(asyncio.create_task, _open_followers())
|
||||
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:
|
||||
@@ -1768,9 +1766,9 @@ class Controller(object):
|
||||
if author_details: user = author_details
|
||||
|
||||
if handler and hasattr(handler, 'open_following_timeline'):
|
||||
async def _open_following():
|
||||
await handler.open_following_timeline(main_controller=self, session=session_to_use, user_payload=user)
|
||||
wx.CallAfter(asyncio.create_task, _open_following())
|
||||
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:
|
||||
|
||||
@@ -205,6 +205,10 @@ class sessionManagerController(object):
|
||||
# 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
|
||||
@@ -232,6 +236,9 @@ class sessionManagerController(object):
|
||||
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):
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -4,12 +4,11 @@ from __future__ import annotations
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from datetime import datetime
|
||||
|
||||
from approve.translation import translate as _
|
||||
from approve.util import parse_iso_datetime # For parsing ISO timestamps
|
||||
import arrow
|
||||
import languageHandler
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from approve.sessions.blueski.session import Session as BlueskiSession
|
||||
from sessions.blueski.session import Session as BlueskiSession
|
||||
from atproto.xrpc_client import models # For type hinting ATProto models
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -94,12 +93,23 @@ class BlueskiCompose:
|
||||
|
||||
post_text = getattr(record, 'text', '') if not isinstance(record, dict) else record.get('text', '')
|
||||
|
||||
reason = post_data.get("reason")
|
||||
if reason:
|
||||
rtype = getattr(reason, "$type", "") if not isinstance(reason, dict) else reason.get("$type", "")
|
||||
if not rtype and not isinstance(reason, dict):
|
||||
rtype = getattr(reason, "py_type", "")
|
||||
if rtype and "reasonRepost" in rtype:
|
||||
by = getattr(reason, "by", None) if not isinstance(reason, dict) else reason.get("by")
|
||||
by_handle = getattr(by, "handle", "") if by and not isinstance(by, dict) else (by.get("handle", "") if by else "")
|
||||
reason_line = _("Reposted by @{handle}").format(handle=by_handle) if by_handle else _("Reposted")
|
||||
post_text = f"{reason_line}\n{post_text}" if post_text else reason_line
|
||||
|
||||
created_at_str = getattr(record, 'createdAt', '') if not isinstance(record, dict) else record.get('createdAt', '')
|
||||
timestamp_str = ""
|
||||
if created_at_str:
|
||||
try:
|
||||
dt_obj = parse_iso_datetime(created_at_str)
|
||||
timestamp_str = dt_obj.strftime("%I:%M %p - %b %d, %Y") if dt_obj else created_at_str
|
||||
ts = arrow.get(created_at_str)
|
||||
timestamp_str = ts.format(_("dddd, MMMM D, YYYY H:m"), locale=languageHandler.curLang[:2])
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not parse timestamp {created_at_str}: {e}")
|
||||
timestamp_str = created_at_str
|
||||
@@ -143,8 +153,10 @@ class BlueskiCompose:
|
||||
if alt_texts_present: embed_display += _(" (Alt text available)")
|
||||
embed_display += "]"
|
||||
|
||||
elif embed_type in ['app.bsky.embed.record#view', 'app.bsky.embed.record']:
|
||||
elif embed_type in ['app.bsky.embed.record#view', 'app.bsky.embed.record', 'app.bsky.embed.recordWithMedia#view', 'app.bsky.embed.recordWithMedia']:
|
||||
record_embed_data = getattr(embed_data, 'record', None) if hasattr(embed_data, 'record') else embed_data.get('record', None)
|
||||
if record_embed_data and isinstance(record_embed_data, dict):
|
||||
record_embed_data = record_embed_data.get("record") or record_embed_data
|
||||
record_embed_type = getattr(record_embed_data, '$type', '')
|
||||
if not record_embed_type and isinstance(record_embed_data, dict): record_embed_type = record_embed_data.get('$type', '')
|
||||
|
||||
@@ -243,3 +255,275 @@ class BlueskiCompose:
|
||||
display_parts.append(f"\"{body_snippet}\"")
|
||||
|
||||
return " ".join(display_parts).strip()
|
||||
|
||||
|
||||
def compose_post(post, db, settings, relative_times, show_screen_names=False, safe=True):
|
||||
"""
|
||||
Compose a Bluesky post into a list of strings [User, Text, Date, Source].
|
||||
post: dict or ATProto model object.
|
||||
"""
|
||||
# Extract data using getattr for models or .get for dicts
|
||||
def g(obj, key, default=None):
|
||||
if isinstance(obj, dict):
|
||||
return obj.get(key, default)
|
||||
return getattr(obj, key, default)
|
||||
|
||||
# Resolve Post View or Feed View structure
|
||||
# Feed items often have .post field. Direct post objects don't.
|
||||
actual_post = g(post, "post", post)
|
||||
|
||||
record = g(actual_post, "record", {})
|
||||
author = g(actual_post, "author", {})
|
||||
|
||||
# Author
|
||||
handle = g(author, "handle", "")
|
||||
display_name = g(author, "displayName") or g(author, "display_name") or handle or "Unknown"
|
||||
|
||||
if show_screen_names:
|
||||
user_str = f"@{handle}"
|
||||
else:
|
||||
# "Display Name (@handle)"
|
||||
if handle and display_name != handle:
|
||||
user_str = f"{display_name} (@{handle})"
|
||||
else:
|
||||
user_str = f"@{handle}"
|
||||
|
||||
# Text
|
||||
text = g(record, "text", "")
|
||||
|
||||
# Repost reason (so users know why they see an unfamiliar post)
|
||||
reason = g(post, "reason", None)
|
||||
if reason:
|
||||
rtype = g(reason, "$type") or g(reason, "py_type")
|
||||
if rtype and "reasonRepost" in rtype:
|
||||
by = g(reason, "by", {})
|
||||
by_handle = g(by, "handle", "")
|
||||
reason_line = _("Reposted by @{handle}").format(handle=by_handle) if by_handle else _("Reposted")
|
||||
text = f"{reason_line}\n{text}" if text else reason_line
|
||||
|
||||
# Labels / Content Warning
|
||||
labels = g(actual_post, "labels", [])
|
||||
cw_text = ""
|
||||
is_sensitive = False
|
||||
|
||||
for label in labels:
|
||||
val = g(label, "val", "")
|
||||
if val in ["!warn", "porn", "sexual", "nudity", "gore", "graphic-media", "corpse", "self-harm", "hate", "spam", "impersonation"]:
|
||||
is_sensitive = True
|
||||
if not cw_text: cw_text = _("Sensitive Content")
|
||||
elif val.startswith("warn:"):
|
||||
is_sensitive = True
|
||||
cw_text = val.split("warn:", 1)[-1].strip()
|
||||
|
||||
if cw_text:
|
||||
text = f"CW: {cw_text}\n\n{text}"
|
||||
|
||||
# Embeds (Images, Quotes)
|
||||
embed = g(actual_post, "embed", None)
|
||||
if embed:
|
||||
etype = g(embed, "$type") or g(embed, "py_type")
|
||||
if etype and ("images" in etype):
|
||||
images = g(embed, "images", [])
|
||||
if images:
|
||||
text += f"\n[{len(images)} {_('Images')}]"
|
||||
|
||||
# Handle Record (Quote) or RecordWithMedia (Quote + Media)
|
||||
quote_rec = None
|
||||
if etype and ("recordWithMedia" in etype):
|
||||
# Extract the nested record
|
||||
rec_embed = g(embed, "record", {})
|
||||
if rec_embed:
|
||||
quote_rec = g(rec_embed, "record", None) or rec_embed
|
||||
# Also check for media in the wrapper
|
||||
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"\n[{len(images)} {_('Images')}]"
|
||||
|
||||
elif etype and ("record" in etype):
|
||||
# Direct quote
|
||||
quote_rec = g(embed, "record", {})
|
||||
if isinstance(quote_rec, dict):
|
||||
quote_rec = quote_rec.get("record") or quote_rec
|
||||
|
||||
if quote_rec:
|
||||
# It is likely a ViewRecord
|
||||
# Check type (ViewRecord, ViewNotFound, ViewBlocked, etc)
|
||||
qtype = g(quote_rec, "$type") or g(quote_rec, "py_type")
|
||||
|
||||
if qtype and "viewNotFound" in qtype:
|
||||
text += f"\n[{_('Quoted post not found')}]"
|
||||
elif qtype and "viewBlocked" in qtype:
|
||||
text += f"\n[{_('Quoted post blocked')}]"
|
||||
elif qtype and "generatorView" in qtype:
|
||||
# Feed generator
|
||||
gen = g(quote_rec, "displayName", "Feed")
|
||||
text += f"\n[{_('Quoting Feed')}: {gen}]"
|
||||
else:
|
||||
# Assume ViewRecord
|
||||
q_author = g(quote_rec, "author", {})
|
||||
q_handle = g(q_author, "handle", "unknown")
|
||||
|
||||
q_val = g(quote_rec, "value", {})
|
||||
q_text = g(q_val, "text", "")
|
||||
|
||||
if q_text:
|
||||
text += f"\n[{_('Quoting')} @{q_handle}: {q_text}]"
|
||||
else:
|
||||
text += f"\n[{_('Quoting')} @{q_handle}]"
|
||||
|
||||
elif etype and ("external" in etype):
|
||||
ext = g(embed, "external", {})
|
||||
uri = g(ext, "uri", "")
|
||||
title = g(ext, "title", "")
|
||||
text += f"\n[{_('Link')}: {title}]"
|
||||
|
||||
# Date
|
||||
indexed_at = g(actual_post, "indexed_at", "")
|
||||
ts_str = ""
|
||||
if indexed_at:
|
||||
try:
|
||||
# Try arrow parsing
|
||||
import arrow
|
||||
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 (not always available in Bsky view, often just client)
|
||||
# We'll leave it empty or mock it if needed
|
||||
source = "Bluesky"
|
||||
|
||||
|
||||
return [user_str, text, ts_str, source]
|
||||
|
||||
def compose_user(user, db, settings, relative_times, show_screen_names=False, safe=True):
|
||||
"""
|
||||
Compose a Bluesky user for list display.
|
||||
Returns: [User summary string]
|
||||
"""
|
||||
# Extract data using getattr for models or .get for dicts
|
||||
def g(obj, key, default=None):
|
||||
if isinstance(obj, dict):
|
||||
return obj.get(key, default)
|
||||
return getattr(obj, key, default)
|
||||
|
||||
handle = g(user, "handle", "unknown")
|
||||
display_name = g(user, "displayName") or g(user, "display_name") or handle
|
||||
followers = g(user, "followersCount", None)
|
||||
following = g(user, "followsCount", None)
|
||||
posts = g(user, "postsCount", None)
|
||||
created_at = g(user, "createdAt", None)
|
||||
|
||||
ts = ""
|
||||
if created_at:
|
||||
try:
|
||||
import arrow
|
||||
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 = str(created_at)
|
||||
|
||||
parts = [f"{display_name} (@{handle})."]
|
||||
if followers is not None and following is not None and posts is not None:
|
||||
parts.append(_("{followers} followers, {following} following, {posts} posts.").format(
|
||||
followers=followers, following=following, posts=posts
|
||||
))
|
||||
if ts:
|
||||
parts.append(_("Joined {date}").format(date=ts))
|
||||
|
||||
return [" ".join(parts).strip()]
|
||||
|
||||
def compose_convo(convo, db, settings, relative_times, show_screen_names=False, safe=True):
|
||||
"""
|
||||
Compose a Bluesky chat conversation for list display.
|
||||
Returns: [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
|
||||
others = []
|
||||
for m in members:
|
||||
did = g(m, "did", None)
|
||||
if self_did and did == self_did:
|
||||
continue
|
||||
label = g(m, "displayName") or g(m, "display_name") or g(m, "handle", "unknown")
|
||||
others.append(label)
|
||||
if not others:
|
||||
others = [g(m, "displayName") or g(m, "display_name") or g(m, "handle", "unknown") for m in members]
|
||||
participants = ", ".join(others)
|
||||
|
||||
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, "displayName") or g(sender, "display_name") or g(sender, "handle", "")
|
||||
|
||||
# Date (using lastMessage.sentAt)
|
||||
date_str = ""
|
||||
sent_at = None
|
||||
if last_msg_obj:
|
||||
sent_at = g(last_msg_obj, "sentAt") or g(last_msg_obj, "sent_at")
|
||||
|
||||
if sent_at:
|
||||
try:
|
||||
import arrow
|
||||
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:
|
||||
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 in a thread.
|
||||
Returns: [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", {})
|
||||
handle = g(sender, "displayName") or g(sender, "display_name") or g(sender, "handle", "unknown")
|
||||
|
||||
text = g(msg, "text", "")
|
||||
|
||||
sent_at = g(msg, "sentAt") or g(msg, "sent_at")
|
||||
date_str = ""
|
||||
if sent_at:
|
||||
try:
|
||||
import arrow
|
||||
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:
|
||||
date_str = str(sent_at)[:16]
|
||||
|
||||
return [handle, text, date_str]
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
import wx
|
||||
@@ -66,6 +67,7 @@ class Session(base.baseSession):
|
||||
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:
|
||||
@@ -129,9 +131,10 @@ class Session(base.baseSession):
|
||||
self.settings.write()
|
||||
self.logged = True
|
||||
log.debug("Logged in to Bluesky as %s", api.me.handle)
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
log.exception("Bluesky login failed")
|
||||
self.logged = False
|
||||
raise e
|
||||
|
||||
def authorise(self):
|
||||
self._ensure_settings_namespace()
|
||||
@@ -175,7 +178,7 @@ class Session(base.baseSession):
|
||||
_("We could not log in to Bluesky. Please verify your handle and app password."),
|
||||
_("Login error"), wx.ICON_ERROR
|
||||
)
|
||||
return
|
||||
return False
|
||||
return True
|
||||
|
||||
def get_message_url(self, message_id, context=None):
|
||||
@@ -207,6 +210,22 @@ class Session(base.baseSession):
|
||||
"$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()
|
||||
@@ -360,16 +379,164 @@ class Session(base.baseSession):
|
||||
uri = None
|
||||
if not uri:
|
||||
raise RuntimeError("Post did not return a URI")
|
||||
# Store last post id if useful
|
||||
self.db.setdefault("sent", [])
|
||||
self.db["sent"].append(dict(id=uri, text=message))
|
||||
self.save_persistent_data()
|
||||
|
||||
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 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:
|
||||
@@ -415,3 +582,80 @@ class Session(base.baseSession):
|
||||
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()
|
||||
res = api.chat.bsky.convo.list_convos({"limit": limit, "cursor": cursor})
|
||||
return {"items": res.convos, "cursor": res.cursor}
|
||||
|
||||
def get_convo_messages(self, convo_id: str, limit: int = 50, cursor: str | None = None) -> dict[str, Any]:
|
||||
api = self._ensure_client()
|
||||
res = api.chat.bsky.convo.get_messages({"convoId": convo_id, "limit": limit, "cursor": cursor})
|
||||
return {"items": res.messages, "cursor": res.cursor}
|
||||
|
||||
def send_chat_message(self, convo_id: str, text: str) -> Any:
|
||||
api = self._ensure_client()
|
||||
return api.chat.bsky.convo.send_message({
|
||||
"convoId": convo_id,
|
||||
"message": {
|
||||
"text": text
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,393 +1,149 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import wx
|
||||
import languageHandler # Ensure _() is available
|
||||
import logging
|
||||
import wx
|
||||
import config
|
||||
from mysc.repeating_timer import RepeatingTimer
|
||||
import arrow
|
||||
import arrow
|
||||
from datetime import datetime
|
||||
|
||||
import languageHandler
|
||||
from multiplatform_widgets import widgets
|
||||
|
||||
log = logging.getLogger("wxUI.buffers.blueski.panels")
|
||||
|
||||
|
||||
class BlueskiHomeTimelinePanel(object):
|
||||
"""Minimal Home timeline buffer for Bluesky.
|
||||
|
||||
Exposes a .buffer wx.Panel with a List control and provides
|
||||
start_stream()/get_more_items() to fetch items from atproto.
|
||||
"""
|
||||
|
||||
def __init__(self, parent, name: str, session):
|
||||
super().__init__()
|
||||
self.session = session
|
||||
self.account = session.get_name()
|
||||
self.name = name
|
||||
self.type = "home_timeline"
|
||||
self.timeline_algorithm = None
|
||||
self.invisible = True
|
||||
self.needs_init = True
|
||||
self.buffer = _HomePanel(parent, name)
|
||||
self.buffer.session = session
|
||||
self.buffer.name = name
|
||||
# Ensure controller can resolve current account from the GUI panel
|
||||
self.buffer.account = self.account
|
||||
self.items = [] # list of dicts: {uri, author, handle, display_name, text, indexed_at}
|
||||
self.cursor = None
|
||||
self._auto_timer = None
|
||||
|
||||
def start_stream(self, mandatory=False, play_sound=True):
|
||||
"""Fetch newest items and render them."""
|
||||
try:
|
||||
count = self.session.settings["general"]["max_posts_per_call"] or 40
|
||||
except Exception:
|
||||
count = 40
|
||||
try:
|
||||
api = self.session._ensure_client()
|
||||
# The atproto SDK expects params, not raw kwargs
|
||||
try:
|
||||
from atproto import models as at_models # type: ignore
|
||||
params = at_models.AppBskyFeedGetTimeline.Params(
|
||||
limit=count,
|
||||
algorithm=self.timeline_algorithm
|
||||
)
|
||||
res = api.app.bsky.feed.get_timeline(params)
|
||||
except Exception:
|
||||
payload = {"limit": count}
|
||||
if self.timeline_algorithm:
|
||||
payload["algorithm"] = self.timeline_algorithm
|
||||
res = api.app.bsky.feed.get_timeline(payload)
|
||||
feed = getattr(res, "feed", [])
|
||||
self.cursor = getattr(res, "cursor", None)
|
||||
self.items = []
|
||||
for it in feed:
|
||||
post = getattr(it, "post", None)
|
||||
if not post:
|
||||
continue
|
||||
# No additional client-side filtering; server distinguishes timelines correctly
|
||||
record = getattr(post, "record", None)
|
||||
author = getattr(post, "author", None)
|
||||
text = getattr(record, "text", "") if record else ""
|
||||
handle = getattr(author, "handle", "") if author else ""
|
||||
display_name = (
|
||||
getattr(author, "display_name", None)
|
||||
or getattr(author, "displayName", None)
|
||||
or ""
|
||||
) if author else ""
|
||||
indexed_at = getattr(post, "indexed_at", None)
|
||||
item = {
|
||||
"uri": getattr(post, "uri", ""),
|
||||
"author": display_name or handle,
|
||||
"handle": handle,
|
||||
"display_name": display_name,
|
||||
"text": text,
|
||||
"indexed_at": indexed_at,
|
||||
}
|
||||
self._append_item(item, to_top=self._reverse())
|
||||
# Full rerender to ensure column widths and selection
|
||||
self._render_list(replace=True)
|
||||
return len(self.items)
|
||||
except Exception:
|
||||
log.exception("Failed to load Bluesky home timeline")
|
||||
self.buffer.list.clear()
|
||||
self.buffer.list.insert_item(False, _("Error"), _("Could not load timeline."), "")
|
||||
return 0
|
||||
|
||||
def get_more_items(self):
|
||||
if not self.cursor:
|
||||
return 0
|
||||
try:
|
||||
api = self.session._ensure_client()
|
||||
try:
|
||||
from atproto import models as at_models # type: ignore
|
||||
params = at_models.AppBskyFeedGetTimeline.Params(
|
||||
limit=40,
|
||||
cursor=self.cursor,
|
||||
algorithm=self.timeline_algorithm
|
||||
)
|
||||
res = api.app.bsky.feed.get_timeline(params)
|
||||
except Exception:
|
||||
payload = {"limit": 40, "cursor": self.cursor}
|
||||
if self.timeline_algorithm:
|
||||
payload["algorithm"] = self.timeline_algorithm
|
||||
res = api.app.bsky.feed.get_timeline(payload)
|
||||
feed = getattr(res, "feed", [])
|
||||
self.cursor = getattr(res, "cursor", None)
|
||||
new_items = []
|
||||
for it in feed:
|
||||
post = getattr(it, "post", None)
|
||||
if not post:
|
||||
continue
|
||||
# No additional client-side filtering
|
||||
record = getattr(post, "record", None)
|
||||
author = getattr(post, "author", None)
|
||||
text = getattr(record, "text", "") if record else ""
|
||||
handle = getattr(author, "handle", "") if author else ""
|
||||
display_name = (
|
||||
getattr(author, "display_name", None)
|
||||
or getattr(author, "displayName", None)
|
||||
or ""
|
||||
) if author else ""
|
||||
indexed_at = getattr(post, "indexed_at", None)
|
||||
new_items.append({
|
||||
"uri": getattr(post, "uri", ""),
|
||||
"author": display_name or handle,
|
||||
"handle": handle,
|
||||
"display_name": display_name,
|
||||
"text": text,
|
||||
"indexed_at": indexed_at,
|
||||
})
|
||||
if not new_items:
|
||||
return 0
|
||||
for it in new_items:
|
||||
self._append_item(it, to_top=self._reverse())
|
||||
# Render only the newly added slice
|
||||
self._render_list(replace=False, start=len(self.items) - len(new_items))
|
||||
return len(new_items)
|
||||
except Exception:
|
||||
log.exception("Failed to load more Bluesky timeline items")
|
||||
return 0
|
||||
|
||||
# Alias to integrate with mainController expectations for Blueski
|
||||
def load_more_posts(self, *args, **kwargs):
|
||||
return self.get_more_items()
|
||||
|
||||
def _reverse(self) -> bool:
|
||||
try:
|
||||
return bool(self.session.settings["general"].get("reverse_timelines", False))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def _append_item(self, item: dict, to_top: bool = False):
|
||||
if to_top:
|
||||
self.items.insert(0, item)
|
||||
else:
|
||||
self.items.append(item)
|
||||
|
||||
def _render_list(self, replace: bool, start: int = 0):
|
||||
if replace:
|
||||
self.buffer.list.clear()
|
||||
for i in range(start, len(self.items)):
|
||||
it = self.items[i]
|
||||
dt = ""
|
||||
if it.get("indexed_at"):
|
||||
try:
|
||||
# Mastodon-like date formatting: relative or full date
|
||||
rel = False
|
||||
try:
|
||||
rel = bool(self.session.settings["general"].get("relative_times", False))
|
||||
except Exception:
|
||||
rel = False
|
||||
ts = arrow.get(str(it["indexed_at"]))
|
||||
if rel:
|
||||
dt = ts.humanize(locale=languageHandler.curLang[:2])
|
||||
else:
|
||||
dt = ts.format(_("dddd, MMMM D, YYYY H:m:s"), locale=languageHandler.curLang[:2])
|
||||
except Exception:
|
||||
try:
|
||||
dt = str(it["indexed_at"])[:16].replace("T", " ")
|
||||
except Exception:
|
||||
dt = ""
|
||||
text = it.get("text", "").replace("\n", " ")
|
||||
if len(text) > 200:
|
||||
text = text[:197] + "..."
|
||||
# Display name and handle like Mastodon: "Display (@handle)"
|
||||
author_col = it.get("author", "")
|
||||
handle = it.get("handle", "")
|
||||
if handle and it.get("display_name"):
|
||||
author_col = f"{it.get('display_name')} (@{handle})"
|
||||
elif handle and not author_col:
|
||||
author_col = f"@{handle}"
|
||||
self.buffer.list.insert_item(False, author_col, text, dt)
|
||||
|
||||
# For compatibility with controller expectations
|
||||
def save_positions(self):
|
||||
try:
|
||||
pos = self.buffer.list.get_selected()
|
||||
self.session.db[self.name + "_pos"] = pos
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Support actions that need a selected item identifier (e.g., reply)
|
||||
def get_selected_item_id(self):
|
||||
try:
|
||||
idx = self.buffer.list.get_selected()
|
||||
if idx is None or idx < 0:
|
||||
return None
|
||||
return self.items[idx].get("uri")
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def get_message(self):
|
||||
try:
|
||||
idx = self.buffer.list.get_selected()
|
||||
if idx is None or idx < 0:
|
||||
return ""
|
||||
it = self.items[idx]
|
||||
author = it.get("display_name") or it.get("author") or ""
|
||||
handle = it.get("handle")
|
||||
if handle:
|
||||
author = f"{author} (@{handle})" if author else f"@{handle}"
|
||||
text = it.get("text", "").replace("\n", " ")
|
||||
dt = ""
|
||||
if it.get("indexed_at"):
|
||||
try:
|
||||
dt = str(it["indexed_at"])[:16].replace("T", " ")
|
||||
except Exception:
|
||||
dt = ""
|
||||
parts = [p for p in [author, text, dt] if p]
|
||||
return ", ".join(parts)
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
# Auto-refresh support (polling) to simulate near real-time updates
|
||||
def _periodic_refresh(self):
|
||||
try:
|
||||
# Ensure UI updates happen on the main thread
|
||||
wx.CallAfter(self.start_stream, False, False)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def enable_auto_refresh(self, seconds: int | None = None):
|
||||
try:
|
||||
if self._auto_timer:
|
||||
return
|
||||
if seconds is None:
|
||||
# Use global update_period (minutes) → seconds; minimum 15s
|
||||
minutes = config.app["app-settings"].get("update_period", 2)
|
||||
seconds = max(15, int(minutes * 60))
|
||||
self._auto_timer = RepeatingTimer(seconds, self._periodic_refresh)
|
||||
self._auto_timer.start()
|
||||
except Exception:
|
||||
log.exception("Failed to enable auto refresh for Bluesky panel %s", self.name)
|
||||
|
||||
def disable_auto_refresh(self):
|
||||
try:
|
||||
if self._auto_timer:
|
||||
self._auto_timer.stop()
|
||||
self._auto_timer = None
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
class _HomePanel(wx.Panel):
|
||||
def __init__(self, parent, name):
|
||||
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"
|
||||
sizer = wx.BoxSizer(wx.VERTICAL)
|
||||
self.list = widgets.list(self, _("Author"), _("Post"), _("Date"), style=wx.LC_REPORT | wx.LC_SINGLE_SEL)
|
||||
|
||||
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, 360)
|
||||
self.list.set_windows_size(2, 150)
|
||||
self.list.set_windows_size(1, 400)
|
||||
self.list.set_windows_size(2, 120)
|
||||
self.list.set_size()
|
||||
sizer.Add(self.list.list, 1, wx.EXPAND | wx.ALL, 0)
|
||||
self.SetSizer(sizer)
|
||||
|
||||
# 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)
|
||||
|
||||
class BlueskiFollowingTimelinePanel(BlueskiHomeTimelinePanel):
|
||||
"""Following-only timeline (reverse-chronological)."""
|
||||
# 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_SET_FOCUS, func)
|
||||
|
||||
def set_position(self, reverse):
|
||||
if reverse:
|
||||
self.list.select_item(0)
|
||||
else:
|
||||
self.list.select_item(self.list.get_count() - 1)
|
||||
|
||||
def __init__(self, parent, name: str, session):
|
||||
super().__init__(parent, name, session)
|
||||
self.type = "following_timeline"
|
||||
self.timeline_algorithm = "reverse-chronological"
|
||||
# Make sure the underlying wx panel also reflects this type
|
||||
try:
|
||||
self.buffer.type = "following_timeline"
|
||||
except Exception:
|
||||
pass
|
||||
def set_focus_in_list(self):
|
||||
self.list.list.SetFocus()
|
||||
|
||||
def start_stream(self, mandatory=False, play_sound=True):
|
||||
try:
|
||||
count = self.session.settings["general"]["max_posts_per_call"] or 40
|
||||
except Exception:
|
||||
count = 40
|
||||
try:
|
||||
api = self.session._ensure_client()
|
||||
# Following timeline via reverse-chronological algorithm on get_timeline
|
||||
# Use plain dict to avoid typed-model mismatches across SDK versions
|
||||
res = api.app.bsky.feed.get_timeline({"limit": count, "algorithm": self.timeline_algorithm})
|
||||
feed = getattr(res, "feed", [])
|
||||
self.cursor = getattr(res, "cursor", None)
|
||||
self.items = []
|
||||
for it in feed:
|
||||
post = getattr(it, "post", None)
|
||||
if not post:
|
||||
continue
|
||||
record = getattr(post, "record", None)
|
||||
author = getattr(post, "author", None)
|
||||
text = getattr(record, "text", "") if record else ""
|
||||
handle = getattr(author, "handle", "") if author else ""
|
||||
display_name = (
|
||||
getattr(author, "display_name", None)
|
||||
or getattr(author, "displayName", None)
|
||||
or ""
|
||||
) if author else ""
|
||||
indexed_at = getattr(post, "indexed_at", None)
|
||||
item = {
|
||||
"uri": getattr(post, "uri", ""),
|
||||
"author": display_name or handle,
|
||||
"handle": handle,
|
||||
"display_name": display_name,
|
||||
"text": text,
|
||||
"indexed_at": indexed_at,
|
||||
}
|
||||
self._append_item(item, to_top=self._reverse())
|
||||
self._render_list(replace=True)
|
||||
return len(self.items)
|
||||
except Exception:
|
||||
log.exception("Failed to load Bluesky following timeline")
|
||||
self.buffer.list.clear()
|
||||
self.buffer.list.insert_item(False, _("Error"), _("Could not load timeline."), "")
|
||||
return 0
|
||||
class NotificationPanel(HomePanel):
|
||||
pass
|
||||
|
||||
def get_more_items(self):
|
||||
if not self.cursor:
|
||||
return 0
|
||||
try:
|
||||
api = self.session._ensure_client()
|
||||
# Pagination via reverse-chronological algorithm on get_timeline
|
||||
res = api.app.bsky.feed.get_timeline({
|
||||
"limit": 40,
|
||||
"cursor": self.cursor,
|
||||
"algorithm": self.timeline_algorithm
|
||||
})
|
||||
feed = getattr(res, "feed", [])
|
||||
self.cursor = getattr(res, "cursor", None)
|
||||
new_items = []
|
||||
for it in feed:
|
||||
post = getattr(it, "post", None)
|
||||
if not post:
|
||||
continue
|
||||
record = getattr(post, "record", None)
|
||||
author = getattr(post, "author", None)
|
||||
text = getattr(record, "text", "") if record else ""
|
||||
handle = getattr(author, "handle", "") if author else ""
|
||||
display_name = (
|
||||
getattr(author, "display_name", None)
|
||||
or getattr(author, "displayName", None)
|
||||
or ""
|
||||
) if author else ""
|
||||
indexed_at = getattr(post, "indexed_at", None)
|
||||
new_items.append({
|
||||
"uri": getattr(post, "uri", ""),
|
||||
"author": display_name or handle,
|
||||
"handle": handle,
|
||||
"display_name": display_name,
|
||||
"text": text,
|
||||
"indexed_at": indexed_at,
|
||||
})
|
||||
if not new_items:
|
||||
return 0
|
||||
for it in new_items:
|
||||
self._append_item(it, to_top=self._reverse())
|
||||
self._render_list(replace=False, start=len(self.items) - len(new_items))
|
||||
return len(new_items)
|
||||
except Exception:
|
||||
log.exception("Failed to load more items for following timeline")
|
||||
return 0
|
||||
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_SET_FOCUS, 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):
|
||||
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: Participants, Last Message, Date
|
||||
self.list = widgets.list(self, _("Participants"), _("Last Message"), _("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()
|
||||
|
||||
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_SET_FOCUS, func)
|
||||
|
||||
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.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()
|
||||
|
||||
@@ -9,10 +9,10 @@ class basePanel(wx.Panel):
|
||||
|
||||
def create_list(self):
|
||||
self.list = widgets.list(self, _(u"User"), _(u"Text"), _(u"Date"), _(u"Client"), style=wx.LC_REPORT|wx.LC_SINGLE_SEL|wx.LC_VRULES)
|
||||
self.list.set_windows_size(0, 60)
|
||||
self.list.set_windows_size(1, 320)
|
||||
self.list.set_windows_size(2, 110)
|
||||
self.list.set_windows_size(3, 84)
|
||||
self.list.set_windows_size(0, 200)
|
||||
self.list.set_windows_size(1, 600)
|
||||
self.list.set_windows_size(2, 200)
|
||||
self.list.set_windows_size(3, 200)
|
||||
self.list.set_size()
|
||||
|
||||
def __init__(self, parent, name):
|
||||
@@ -35,7 +35,7 @@ class basePanel(wx.Panel):
|
||||
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, 0, wx.ALL|wx.EXPAND, 5)
|
||||
self.sizer.Add(self.list.list, 1, wx.ALL|wx.EXPAND, 5)
|
||||
self.SetSizer(self.sizer)
|
||||
self.SetClientSize(self.sizer.CalcMin())
|
||||
|
||||
|
||||
@@ -9,10 +9,10 @@ class conversationListPanel(wx.Panel):
|
||||
|
||||
def create_list(self):
|
||||
self.list = widgets.list(self, _(u"User"), _(u"Text"), _(u"Date"), _(u"Client"), style=wx.LC_REPORT|wx.LC_SINGLE_SEL|wx.LC_VRULES)
|
||||
self.list.set_windows_size(0, 60)
|
||||
self.list.set_windows_size(1, 320)
|
||||
self.list.set_windows_size(2, 110)
|
||||
self.list.set_windows_size(3, 84)
|
||||
self.list.set_windows_size(0, 200)
|
||||
self.list.set_windows_size(1, 600)
|
||||
self.list.set_windows_size(2, 200)
|
||||
self.list.set_windows_size(3, 200)
|
||||
self.list.set_size()
|
||||
|
||||
def __init__(self, parent, name):
|
||||
@@ -27,7 +27,7 @@ class conversationListPanel(wx.Panel):
|
||||
btnSizer.Add(self.post, 0, wx.ALL, 5)
|
||||
btnSizer.Add(self.reply, 0, wx.ALL, 5)
|
||||
self.sizer.Add(btnSizer, 0, wx.ALL, 5)
|
||||
self.sizer.Add(self.list.list, 0, wx.ALL|wx.EXPAND, 5)
|
||||
self.sizer.Add(self.list.list, 1, wx.ALL|wx.EXPAND, 5)
|
||||
self.SetSizer(self.sizer)
|
||||
self.SetClientSize(self.sizer.CalcMin())
|
||||
|
||||
|
||||
@@ -9,8 +9,8 @@ class notificationsPanel(wx.Panel):
|
||||
|
||||
def create_list(self):
|
||||
self.list = widgets.list(self, _("Text"), _("Date"), style=wx.LC_REPORT|wx.LC_SINGLE_SEL|wx.LC_VRULES)
|
||||
self.list.set_windows_size(0, 320)
|
||||
self.list.set_windows_size(2, 110)
|
||||
self.list.set_windows_size(0, 600)
|
||||
self.list.set_windows_size(1, 200)
|
||||
self.list.set_size()
|
||||
|
||||
def __init__(self, parent, name):
|
||||
@@ -25,7 +25,7 @@ class notificationsPanel(wx.Panel):
|
||||
btnSizer.Add(self.post, 0, wx.ALL, 5)
|
||||
btnSizer.Add(self.dismiss, 0, wx.ALL, 5)
|
||||
self.sizer.Add(btnSizer, 0, wx.ALL, 5)
|
||||
self.sizer.Add(self.list.list, 0, wx.ALL|wx.EXPAND, 5)
|
||||
self.sizer.Add(self.list.list, 1, wx.ALL|wx.EXPAND, 5)
|
||||
self.SetSizer(self.sizer)
|
||||
self.SetClientSize(self.sizer.CalcMin())
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ class userPanel(wx.Panel):
|
||||
|
||||
def create_list(self):
|
||||
self.list = widgets.list(self, _("User"), style=wx.LC_REPORT|wx.LC_SINGLE_SEL|wx.LC_VRULES)
|
||||
self.list.set_windows_size(0, 320)
|
||||
self.list.set_windows_size(0, 600)
|
||||
self.list.set_size()
|
||||
|
||||
def __init__(self, parent, name):
|
||||
@@ -23,7 +23,7 @@ class userPanel(wx.Panel):
|
||||
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, 0, wx.ALL|wx.EXPAND, 5)
|
||||
self.sizer.Add(self.list.list, 1, wx.ALL|wx.EXPAND, 5)
|
||||
self.SetSizer(self.sizer)
|
||||
self.SetClientSize(self.sizer.CalcMin())
|
||||
|
||||
|
||||
@@ -9,7 +9,10 @@ class Post(wx.Dialog):
|
||||
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)
|
||||
|
||||
@@ -58,6 +61,7 @@ class Post(wx.Dialog):
|
||||
|
||||
self.SetSizer(main_sizer)
|
||||
main_sizer.Fit(self)
|
||||
self.SetEscapeId(cancel.GetId())
|
||||
self.Layout()
|
||||
|
||||
# Bindings
|
||||
@@ -66,6 +70,13 @@ class Post(wx.Dialog):
|
||||
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_add(self, evt):
|
||||
if self.attach_list.GetItemCount() >= 4:
|
||||
wx.MessageBox(_("You can attach up to 4 images."), _("Attachment limit"), wx.ICON_INFORMATION)
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import wx
|
||||
import asyncio
|
||||
import logging
|
||||
from pubsub import pub
|
||||
import languageHandler
|
||||
import builtins
|
||||
from threading import Thread
|
||||
|
||||
from approve.translation import translate as _
|
||||
from approve.notifications import NotificationError
|
||||
# Assuming controller.blueski.userList.get_user_profile_details and session.util._format_profile_data exist
|
||||
# For direct call to util:
|
||||
# from sessions.blueski import utils as BlueskiUtils
|
||||
_ = getattr(builtins, "_", lambda s: s)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -25,7 +22,7 @@ class ShowUserProfileDialog(wx.Dialog):
|
||||
self.SetMinSize((400, 300))
|
||||
self.CentreOnParent()
|
||||
|
||||
wx.CallAfter(asyncio.create_task, self.load_profile_data())
|
||||
Thread(target=self.load_profile_data, daemon=True).start()
|
||||
|
||||
def _init_ui(self):
|
||||
panel = wx.Panel(self)
|
||||
@@ -36,17 +33,23 @@ class ShowUserProfileDialog(wx.Dialog):
|
||||
self.info_grid_sizer.AddGrowableCol(1, 1)
|
||||
|
||||
fields = [
|
||||
(_("Display Name:"), "displayName"), (_("Handle:"), "handle"), (_("DID:"), "did"),
|
||||
(_("Followers:"), "followersCount"), (_("Following:"), "followsCount"), (_("Posts:"), "postsCount"),
|
||||
(_("Bio:"), "description")
|
||||
(_("&Name:"), "displayName"), (_("&Handle:"), "handle"), (_("&DID:"), "did"),
|
||||
(_("&Followers:"), "followersCount"), (_("&Following:"), "followsCount"), (_("&Posts:"), "postsCount"),
|
||||
(_("&Bio:"), "description")
|
||||
]
|
||||
self.profile_field_ctrls = {}
|
||||
|
||||
for label_text, data_key in fields:
|
||||
lbl = wx.StaticText(panel, label=label_text)
|
||||
val_ctrl = wx.TextCtrl(panel, style=wx.TE_READONLY | wx.TE_MULTILINE if data_key == "description" else wx.TE_READONLY | wx.BORDER_NONE)
|
||||
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(panel, style=style)
|
||||
if data_key != "description": # Make it look like a label
|
||||
val_ctrl.SetBackgroundColour(panel.GetBackgroundColour())
|
||||
val_ctrl.AcceptsFocusFromKeyboard = lambda: True
|
||||
|
||||
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)
|
||||
@@ -89,51 +92,62 @@ class ShowUserProfileDialog(wx.Dialog):
|
||||
main_sizer.Add(actions_sizer, 0, wx.ALIGN_CENTER | wx.TOP | wx.BOTTOM, 10)
|
||||
|
||||
# Close Button
|
||||
close_btn = wx.Button(panel, wx.ID_CANCEL, _("Close"))
|
||||
close_btn = wx.Button(panel, wx.ID_CANCEL, _("&Close"))
|
||||
close_btn.SetDefault() # Allow Esc to close
|
||||
main_sizer.Add(close_btn, 0, wx.ALIGN_RIGHT | wx.ALL, 10)
|
||||
self.SetEscapeId(close_btn.GetId())
|
||||
|
||||
panel.SetSizer(main_sizer)
|
||||
self.Fit() # Fit dialog to content
|
||||
|
||||
async def load_profile_data(self):
|
||||
self.SetStatusText(_("Loading profile..."))
|
||||
def load_profile_data(self):
|
||||
wx.CallAfter(self.SetStatusText, _("Loading profile..."))
|
||||
for ctrl in self.profile_field_ctrls.values():
|
||||
ctrl.SetValue(_("Loading..."))
|
||||
wx.CallAfter(ctrl.SetValue, _("Loading..."))
|
||||
|
||||
# Initially hide all action buttons until state is known
|
||||
self.follow_btn.Hide()
|
||||
self.unfollow_btn.Hide()
|
||||
self.mute_btn.Hide()
|
||||
self.unmute_btn.Hide()
|
||||
self.block_btn.Hide()
|
||||
self.unblock_btn.Hide()
|
||||
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:
|
||||
raw_profile = await self.session.util.get_user_profile(self.user_identifier)
|
||||
if raw_profile:
|
||||
self.profile_data = self.session.util._format_profile_data(raw_profile) # This should return a dict
|
||||
self.target_user_did = self.profile_data.get("did") # Store the canonical DID
|
||||
self.user_identifier = self.target_user_did # Update identifier to resolved DID for consistency
|
||||
|
||||
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)
|
||||
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(_("Error loading."))
|
||||
self.SetStatusText(_("Error loading profile."))
|
||||
wx.MessageBox(_("Error loading profile: {error}").format(error=str(e)), _("Error"), wx.OK | wx.ICON_ERROR, self)
|
||||
finally:
|
||||
self.Layout() # Refresh layout after hiding/showing buttons
|
||||
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:
|
||||
@@ -159,7 +173,7 @@ class ShowUserProfileDialog(wx.Dialog):
|
||||
self.Layout()
|
||||
|
||||
def update_action_buttons_state(self):
|
||||
if not self.profile_data or not self.target_user_did or self.target_user_did == self.session.util.get_own_did():
|
||||
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()
|
||||
@@ -218,80 +232,70 @@ class ShowUserProfileDialog(wx.Dialog):
|
||||
return
|
||||
dlg.Destroy()
|
||||
|
||||
async def do_action():
|
||||
wx.BeginBusyCursor()
|
||||
self.SetStatusText(_("Performing action: {action}...").format(action=command))
|
||||
action_button = event.GetEventObject()
|
||||
if action_button: action_button.Disable() # Disable the clicked button
|
||||
wx.BeginBusyCursor()
|
||||
self.SetStatusText(_("Performing action: {action}...").format(action=command))
|
||||
action_button = event.GetEventObject()
|
||||
if action_button:
|
||||
action_button.Disable()
|
||||
|
||||
try:
|
||||
# Ensure controller_handler is available on the session
|
||||
if not hasattr(self.session, 'controller_handler') or not self.session.controller_handler:
|
||||
app = wx.GetApp()
|
||||
if hasattr(app, 'mainController'):
|
||||
self.session.controller_handler = app.mainController.get_handler(self.session.KIND)
|
||||
if not self.session.controller_handler: # Still not found
|
||||
raise RuntimeError("Controller handler not found for session.")
|
||||
try:
|
||||
if command == "block_user" and hasattr(self.session, "block_user"):
|
||||
ok = self.session.block_user(self.target_user_did)
|
||||
if not ok:
|
||||
raise RuntimeError(_("Failed to block user."))
|
||||
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)
|
||||
if not ok:
|
||||
raise RuntimeError(_("Failed to unblock user."))
|
||||
else:
|
||||
raise RuntimeError(_("This action is not supported yet."))
|
||||
|
||||
result = await self.session.controller_handler.handle_user_command(
|
||||
command=command,
|
||||
user_id=self.session.uid,
|
||||
target_user_id=self.target_user_did,
|
||||
payload={}
|
||||
)
|
||||
wx.EndBusyCursor()
|
||||
# Use CallAfter for UI updates from async task
|
||||
wx.CallAfter(wx.MessageBox, result.get("message", _("Action completed.")),
|
||||
_("Success") if result.get("status") == "success" else _("Error"),
|
||||
wx.OK | (wx.ICON_INFORMATION if result.get("status") == "success" else wx.ICON_ERROR),
|
||||
self)
|
||||
wx.EndBusyCursor()
|
||||
wx.MessageBox(_("Action completed."), _("Success"), wx.OK | wx.ICON_INFORMATION, self)
|
||||
wx.CallAfter(asyncio.create_task, self.load_profile_data())
|
||||
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)
|
||||
|
||||
if result.get("status") == "success":
|
||||
# Re-fetch profile data to update UI (especially button states)
|
||||
wx.CallAfter(asyncio.create_task, self.load_profile_data())
|
||||
else: # Re-enable button if action failed
|
||||
if action_button: wx.CallAfter(action_button.Enable, True)
|
||||
self.SetStatusText(_("Action failed."))
|
||||
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)
|
||||
|
||||
except NotificationError as e:
|
||||
wx.EndBusyCursor()
|
||||
if action_button: wx.CallAfter(action_button.Enable, True)
|
||||
self.SetStatusText(_("Action failed."))
|
||||
wx.CallAfter(wx.MessageBox, str(e), _("Action Error"), wx.OK | wx.ICON_ERROR, self)
|
||||
except Exception as e:
|
||||
wx.EndBusyCursor()
|
||||
if action_button: wx.CallAfter(action_button.Enable, True)
|
||||
self.SetStatusText(_("Action failed."))
|
||||
logger.error(f"Error performing user action '{command}' on {self.target_user_did}: {e}", exc_info=True)
|
||||
wx.CallAfter(wx.MessageBox, _("An unexpected error occurred: {error}").format(error=str(e)), _("Error"), wx.OK | wx.ICON_ERROR, self)
|
||||
|
||||
asyncio.create_task(do_action()) # No wx.CallAfter needed for starting the task itself
|
||||
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": g(profile_model, "followersCount"),
|
||||
"followsCount": g(profile_model, "followsCount"),
|
||||
"postsCount": g(profile_model, "postsCount"),
|
||||
"viewer": g(profile_model, "viewer") or {},
|
||||
}
|
||||
|
||||
def SetStatusText(self, text): # Simple status text for dialog title
|
||||
self.SetTitle(f"{_('User Profile')} - {text}")
|
||||
|
||||
```python
|
||||
# Example of how this dialog might be called from blueski.Handler.user_details:
|
||||
# (This is conceptual, actual integration in handler.py will use the dialog)
|
||||
#
|
||||
# async def user_details(self, buffer_panel_or_user_ident):
|
||||
# session = self._get_session(self.current_user_id_from_context) # Get current session
|
||||
# user_identifier_to_show = None
|
||||
# if isinstance(buffer_panel_or_user_ident, str): # It's a DID or handle
|
||||
# user_identifier_to_show = buffer_panel_or_user_ident
|
||||
# elif hasattr(buffer_panel_or_user_ident, 'get_selected_item_author_details'): # It's a panel
|
||||
# author_details = buffer_panel_or_user_ident.get_selected_item_author_details()
|
||||
# if author_details:
|
||||
# user_identifier_to_show = author_details.get("did") or author_details.get("handle")
|
||||
#
|
||||
# if not user_identifier_to_show:
|
||||
# # Optionally prompt for user_identifier if not found
|
||||
# output.speak(_("No user selected or identified to view details."), True)
|
||||
# return
|
||||
#
|
||||
# dialog = ShowUserProfileDialog(self.main_controller.view, session, user_identifier_to_show)
|
||||
# dialog.ShowModal()
|
||||
# dialog.Destroy()
|
||||
|
||||
```
|
||||
|
||||
85
src/wxUI/dialogs/blueski/userActions.py
Normal file
85
src/wxUI/dialogs/blueski/userActions.py
Normal file
@@ -0,0 +1,85 @@
|
||||
# -*- 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()
|
||||
userSizer.Add(userLabel, 0, wx.ALL, 5)
|
||||
userSizer.Add(self.cb, 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()
|
||||
|
||||
@@ -134,9 +134,9 @@ class mainFrame(wx.Frame):
|
||||
self.buffers[name] = buffer.GetId()
|
||||
|
||||
def prepare(self):
|
||||
self.sizer.Add(self.nb, 0, wx.ALL, 5)
|
||||
self.sizer.Add(self.nb, 1, wx.ALL | wx.EXPAND, 5)
|
||||
self.panel.SetSizer(self.sizer)
|
||||
# self.Maximize()
|
||||
self.Maximize()
|
||||
self.sizer.Layout()
|
||||
self.SetClientSize(self.sizer.CalcMin())
|
||||
# print self.GetSize()
|
||||
|
||||
Reference in New Issue
Block a user