2025-05-26 14:11:01 +00:00
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import logging
|
|
|
|
|
from typing import TYPE_CHECKING, Any, AsyncGenerator
|
|
|
|
|
|
2026-02-01 13:17:49 +01:00
|
|
|
import widgetUtils
|
2026-02-01 13:16:54 +01:00
|
|
|
from wxUI.dialogs.blueski.showUserProfile import ShowUserProfileDialog
|
|
|
|
|
from controller.userList import UserListController
|
|
|
|
|
from controller.blueski import userActions as user_actions_controller
|
|
|
|
|
|
2025-05-26 14:11:01 +00:00
|
|
|
fromapprove.translation import translate as _
|
|
|
|
|
# fromapprove.controller.mastodon import userList as mastodon_user_list # If adapting
|
|
|
|
|
|
|
|
|
|
if TYPE_CHECKING:
|
2026-01-10 19:46:53 +01:00
|
|
|
fromapprove.sessions.blueski.session import Session as BlueskiSession # Adjusted
|
|
|
|
|
# Define a type for what a user entry in a list might look like for Blueski
|
|
|
|
|
BlueskiUserListItem = dict[str, Any] # e.g. {"did": "...", "handle": "...", "displayName": "..."}
|
2025-05-26 14:11:01 +00:00
|
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
2026-01-10 19:46:53 +01:00
|
|
|
# This file is responsible for fetching and managing lists of users from Blueski.
|
2025-05-26 14:11:01 +00:00
|
|
|
# Examples include:
|
|
|
|
|
# - Followers of a user
|
|
|
|
|
# - Users a user is following
|
|
|
|
|
# - Users who liked or reposted a post
|
2026-01-10 19:46:53 +01:00
|
|
|
# - Users in a specific list or feed (if Blueski supports user lists like Twitter/Mastodon)
|
2025-05-26 14:11:01 +00:00
|
|
|
# - Search results for users
|
|
|
|
|
|
|
|
|
|
# The structure will likely involve:
|
2026-01-10 19:46:53 +01:00
|
|
|
# - A base class or functions for paginating through user lists from the Blueski API.
|
2025-05-26 14:11:01 +00:00
|
|
|
# - Specific functions for each type of user list.
|
2026-01-10 19:46:53 +01:00
|
|
|
# - Formatting Blueski user data into a consistent structure for UI display.
|
2025-05-26 14:11:01 +00:00
|
|
|
|
|
|
|
|
async def fetch_followers(
|
2026-01-10 19:46:53 +01:00
|
|
|
session: BlueskiSession,
|
2025-05-26 14:11:01 +00:00
|
|
|
user_id: str, # DID of the user whose followers to fetch
|
|
|
|
|
limit: int = 20,
|
|
|
|
|
cursor: str | None = None
|
2026-01-10 19:46:53 +01:00
|
|
|
) -> AsyncGenerator[BlueskiUserListItem, None]:
|
2025-05-26 14:11:01 +00:00
|
|
|
"""
|
2026-01-10 19:46:53 +01:00
|
|
|
Asynchronously fetches a list of followers for a given Blueski user.
|
2025-05-26 14:11:01 +00:00
|
|
|
user_id is the DID of the target user.
|
|
|
|
|
Yields user data dictionaries.
|
|
|
|
|
"""
|
|
|
|
|
# client = await session.util._get_client() # Get authenticated client
|
|
|
|
|
# if not client:
|
2026-01-10 19:46:53 +01:00
|
|
|
# logger.warning(f"Blueski client not available for fetching followers of {user_id}.")
|
2025-05-26 14:11:01 +00:00
|
|
|
# return
|
|
|
|
|
|
|
|
|
|
# current_cursor = cursor
|
|
|
|
|
# try:
|
|
|
|
|
# while True:
|
|
|
|
|
# # response = await client.app.bsky.graph.get_followers(
|
|
|
|
|
# # models.AppBskyGraphGetFollowers.Params(
|
|
|
|
|
# # actor=user_id,
|
|
|
|
|
# # limit=min(limit, 100), # ATProto API might have its own max limit per request (e.g. 100)
|
|
|
|
|
# # cursor=current_cursor
|
|
|
|
|
# # )
|
|
|
|
|
# # )
|
|
|
|
|
# # if not response or not response.followers:
|
|
|
|
|
# # break
|
2025-05-30 16:16:21 +00:00
|
|
|
|
2025-05-26 14:11:01 +00:00
|
|
|
# # for user_profile_view in response.followers:
|
|
|
|
|
# # yield session.util._format_profile_data(user_profile_view) # Use a utility to standardize format
|
|
|
|
|
|
|
|
|
|
# # current_cursor = response.cursor
|
|
|
|
|
# # if not current_cursor or len(response.followers) < limit : # Or however the API indicates end of list
|
|
|
|
|
# # break
|
2025-05-30 16:16:21 +00:00
|
|
|
|
2025-05-26 14:11:01 +00:00
|
|
|
# # This is a placeholder loop for demonstration
|
|
|
|
|
# if current_cursor == "simulated_end_cursor": break # Stop after one simulated page
|
|
|
|
|
# for i in range(limit):
|
|
|
|
|
# if current_cursor and int(current_cursor) + i >= 25: # Simulate total 25 followers
|
|
|
|
|
# current_cursor = "simulated_end_cursor"
|
|
|
|
|
# break
|
|
|
|
|
# yield {
|
|
|
|
|
# "did": f"did:plc:follower{i + (int(current_cursor) if current_cursor else 0)}",
|
|
|
|
|
# "handle": f"follower{i + (int(current_cursor) if current_cursor else 0)}.bsky.social",
|
|
|
|
|
# "displayName": f"Follower {i + (int(current_cursor) if current_cursor else 0)}",
|
|
|
|
|
# "avatar": None # Placeholder
|
|
|
|
|
# }
|
|
|
|
|
# if not current_cursor: current_cursor = str(limit) # Simulate next cursor
|
|
|
|
|
# elif current_cursor != "simulated_end_cursor": current_cursor = str(int(current_cursor) + limit)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
"""
|
|
|
|
|
if not session.is_ready():
|
2026-01-10 19:46:53 +01:00
|
|
|
logger.warning(f"Cannot fetch followers for {user_id}: Blueski session not ready.")
|
2025-05-26 14:11:01 +00:00
|
|
|
# yield {} # Stop iteration if not ready
|
|
|
|
|
return
|
2025-05-30 16:16:21 +00:00
|
|
|
|
2025-05-26 14:11:01 +00:00
|
|
|
try:
|
|
|
|
|
followers_data = await session.util.get_followers(user_did=user_id, limit=limit, cursor=cursor)
|
|
|
|
|
if followers_data:
|
|
|
|
|
users, _ = followers_data # We'll return the cursor separately via the calling HTTP handler
|
|
|
|
|
for user_profile_view in users:
|
|
|
|
|
yield session.util._format_profile_data(user_profile_view)
|
|
|
|
|
else:
|
|
|
|
|
logger.info(f"No followers data returned for user {user_id}.")
|
2025-05-30 16:16:21 +00:00
|
|
|
|
2025-05-26 14:11:01 +00:00
|
|
|
except Exception as e:
|
2026-01-10 19:46:53 +01:00
|
|
|
logger.error(f"Error in fetch_followers for Blueski user {user_id}: {e}", exc_info=True)
|
2025-05-26 14:11:01 +00:00
|
|
|
# Depending on desired error handling, could raise or yield an error marker
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def fetch_following(
|
2026-01-10 19:46:53 +01:00
|
|
|
session: BlueskiSession,
|
2025-05-26 14:11:01 +00:00
|
|
|
user_id: str, # DID of the user whose followed accounts to fetch
|
|
|
|
|
limit: int = 20,
|
|
|
|
|
cursor: str | None = None
|
2026-01-10 19:46:53 +01:00
|
|
|
) -> AsyncGenerator[BlueskiUserListItem, None]:
|
2025-05-26 14:11:01 +00:00
|
|
|
"""
|
2026-01-10 19:46:53 +01:00
|
|
|
Asynchronously fetches a list of users followed by a given Blueski user.
|
2025-05-26 14:11:01 +00:00
|
|
|
Yields user data dictionaries.
|
|
|
|
|
"""
|
|
|
|
|
if not session.is_ready():
|
2026-01-10 19:46:53 +01:00
|
|
|
logger.warning(f"Cannot fetch following for {user_id}: Blueski session not ready.")
|
2025-05-26 14:11:01 +00:00
|
|
|
return
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
following_data = await session.util.get_following(user_did=user_id, limit=limit, cursor=cursor)
|
|
|
|
|
if following_data:
|
|
|
|
|
users, _ = following_data
|
|
|
|
|
for user_profile_view in users:
|
|
|
|
|
yield session.util._format_profile_data(user_profile_view)
|
|
|
|
|
else:
|
|
|
|
|
logger.info(f"No following data returned for user {user_id}.")
|
2025-05-30 16:16:21 +00:00
|
|
|
|
2025-05-26 14:11:01 +00:00
|
|
|
except Exception as e:
|
2026-01-10 19:46:53 +01:00
|
|
|
logger.error(f"Error in fetch_following for Blueski user {user_id}: {e}", exc_info=True)
|
2025-05-26 14:11:01 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
async def search_users(
|
2026-01-10 19:46:53 +01:00
|
|
|
session: BlueskiSession,
|
2025-05-26 14:11:01 +00:00
|
|
|
query: str,
|
|
|
|
|
limit: int = 20,
|
2025-05-30 16:16:21 +00:00
|
|
|
cursor: str | None = None
|
2026-01-10 19:46:53 +01:00
|
|
|
) -> AsyncGenerator[BlueskiUserListItem, None]:
|
2025-05-26 14:11:01 +00:00
|
|
|
"""
|
2026-01-10 19:46:53 +01:00
|
|
|
Searches for users on Blueski based on a query string.
|
2025-05-26 14:11:01 +00:00
|
|
|
Yields user data dictionaries.
|
|
|
|
|
"""
|
|
|
|
|
if not session.is_ready():
|
2026-01-10 19:46:53 +01:00
|
|
|
logger.warning(f"Cannot search users for '{query}': Blueski session not ready.")
|
2025-05-26 14:11:01 +00:00
|
|
|
return
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
search_data = await session.util.search_users(term=query, limit=limit, cursor=cursor)
|
|
|
|
|
if search_data:
|
|
|
|
|
users, _ = search_data
|
|
|
|
|
for user_profile_view in users:
|
|
|
|
|
yield session.util._format_profile_data(user_profile_view)
|
|
|
|
|
else:
|
|
|
|
|
logger.info(f"No users found for search term '{query}'.")
|
2025-05-30 16:16:21 +00:00
|
|
|
|
2025-05-26 14:11:01 +00:00
|
|
|
except Exception as e:
|
2026-01-10 19:46:53 +01:00
|
|
|
logger.error(f"Error in search_users for Blueski query '{query}': {e}", exc_info=True)
|
2025-05-26 14:11:01 +00:00
|
|
|
|
|
|
|
|
# This function is designed to be called by an API endpoint that returns JSON
|
|
|
|
|
async def get_user_list_paginated(
|
2026-01-10 19:46:53 +01:00
|
|
|
session: BlueskiSession,
|
2025-05-26 14:11:01 +00:00
|
|
|
list_type: str, # "followers", "following", "search"
|
|
|
|
|
identifier: str, # User DID for followers/following, or search query for search
|
|
|
|
|
limit: int = 20,
|
|
|
|
|
cursor: str | None = None
|
2026-01-10 19:46:53 +01:00
|
|
|
) -> tuple[list[BlueskiUserListItem], str | None]:
|
2025-05-26 14:11:01 +00:00
|
|
|
"""
|
|
|
|
|
Fetches a paginated list of users (followers, following, or search results)
|
|
|
|
|
and returns the list and the next cursor.
|
|
|
|
|
"""
|
2026-01-10 19:46:53 +01:00
|
|
|
users_list: list[BlueskiUserListItem] = []
|
2025-05-26 14:11:01 +00:00
|
|
|
next_cursor: str | None = None
|
|
|
|
|
|
|
|
|
|
if not session.is_ready():
|
2026-01-10 19:46:53 +01:00
|
|
|
logger.warning(f"Cannot fetch user list '{list_type}': Blueski session not ready.")
|
2025-05-26 14:11:01 +00:00
|
|
|
return [], None
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
if list_type == "followers":
|
|
|
|
|
data = await session.util.get_followers(user_did=identifier, limit=limit, cursor=cursor)
|
|
|
|
|
if data: users_list = [session.util._format_profile_data(u) for u in data[0]]; next_cursor = data[1]
|
|
|
|
|
elif list_type == "following":
|
|
|
|
|
data = await session.util.get_following(user_did=identifier, limit=limit, cursor=cursor)
|
|
|
|
|
if data: users_list = [session.util._format_profile_data(u) for u in data[0]]; next_cursor = data[1]
|
|
|
|
|
elif list_type == "search_users":
|
|
|
|
|
data = await session.util.search_users(term=identifier, limit=limit, cursor=cursor)
|
|
|
|
|
if data: users_list = [session.util._format_profile_data(u) for u in data[0]]; next_cursor = data[1]
|
|
|
|
|
else:
|
|
|
|
|
logger.error(f"Unknown list_type: {list_type}")
|
|
|
|
|
return [], None
|
2025-05-30 16:16:21 +00:00
|
|
|
|
2025-05-26 14:11:01 +00:00
|
|
|
except Exception as e:
|
|
|
|
|
logger.error(f"Error fetching paginated user list '{list_type}' for '{identifier}': {e}", exc_info=True)
|
|
|
|
|
# Optionally re-raise or return empty with no cursor to indicate error
|
2025-05-30 16:16:21 +00:00
|
|
|
return [], None
|
|
|
|
|
|
2025-05-26 14:11:01 +00:00
|
|
|
return users_list, next_cursor
|
|
|
|
|
|
|
|
|
|
|
2026-01-10 19:46:53 +01:00
|
|
|
async def get_user_profile_details(session: BlueskiSession, user_ident: str) -> BlueskiUserListItem | None:
|
2025-05-26 14:11:01 +00:00
|
|
|
"""
|
|
|
|
|
Fetches detailed profile information for a user by DID or handle.
|
|
|
|
|
Returns a dictionary of formatted profile data, or None if not found/error.
|
|
|
|
|
"""
|
|
|
|
|
if not session.is_ready():
|
2026-01-10 19:46:53 +01:00
|
|
|
logger.warning(f"Cannot get profile for {user_ident}: Blueski session not ready.")
|
2025-05-26 14:11:01 +00:00
|
|
|
return None
|
2025-05-30 16:16:21 +00:00
|
|
|
|
2025-05-26 14:11:01 +00:00
|
|
|
try:
|
|
|
|
|
profile_view_detailed = await session.util.get_user_profile(user_ident=user_ident)
|
|
|
|
|
if profile_view_detailed:
|
|
|
|
|
return session.util._format_profile_data(profile_view_detailed)
|
|
|
|
|
else:
|
|
|
|
|
logger.info(f"No profile data found for user {user_ident}.")
|
|
|
|
|
return None
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error(f"Error in get_user_profile_details for {user_ident}: {e}", exc_info=True)
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# Other list types could include:
|
|
|
|
|
# - fetch_likers(session, post_uri, limit, cursor) # Needs app.bsky.feed.getLikes
|
|
|
|
|
# - fetch_reposters(session, post_uri, limit, cursor)
|
|
|
|
|
# - fetch_muted_users(session, limit, cursor)
|
|
|
|
|
# - fetch_blocked_users(session, limit, cursor)
|
|
|
|
|
|
|
|
|
|
# The UI part of Approve that displays user lists would call these functions.
|
|
|
|
|
# Each function needs to handle pagination as provided by the ATProto API (usually cursor-based).
|
|
|
|
|
|
2026-01-10 19:46:53 +01:00
|
|
|
logger.info("Blueski userList module loaded (placeholders).")
|
2026-02-01 13:16:54 +01:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class BlueskyUserList(UserListController):
|
2026-02-01 13:17:49 +01:00
|
|
|
def __init__(self, users, session, title, fetch_fn=None, cursor=None):
|
|
|
|
|
self.session = session
|
|
|
|
|
self.users = self.process_users(users)
|
|
|
|
|
self._fetch_fn = fetch_fn
|
|
|
|
|
self._cursor = cursor
|
|
|
|
|
from wxUI.dialogs import userList
|
|
|
|
|
self.dialog = userList.UserListDialog(title=title, users=[user.get("display_name") for user in self.users])
|
|
|
|
|
widgetUtils.connect_event(self.dialog.actions_button, widgetUtils.BUTTON_PRESSED, self.on_actions)
|
|
|
|
|
widgetUtils.connect_event(self.dialog.details_button, widgetUtils.BUTTON_PRESSED, self.on_details)
|
|
|
|
|
self._enable_pagination()
|
|
|
|
|
self.dialog.ShowModal()
|
2026-02-01 13:16:54 +01:00
|
|
|
def process_users(self, users):
|
|
|
|
|
def g(obj, key, default=None):
|
|
|
|
|
if isinstance(obj, dict):
|
|
|
|
|
return obj.get(key, default)
|
|
|
|
|
return getattr(obj, key, default)
|
|
|
|
|
|
|
|
|
|
processed = []
|
|
|
|
|
for item in users or []:
|
|
|
|
|
actor = g(item, "actor") or g(item, "user") or item
|
|
|
|
|
did = g(actor, "did")
|
|
|
|
|
handle = g(actor, "handle")
|
|
|
|
|
display_name = g(actor, "displayName") or g(actor, "display_name") or handle or "Unknown"
|
|
|
|
|
label = f"{display_name} (@{handle})" if handle and display_name != handle else (f"@{handle}" if handle else display_name)
|
|
|
|
|
processed.append(dict(did=did, handle=handle, display_name=label))
|
|
|
|
|
return processed
|
|
|
|
|
|
|
|
|
|
def on_actions(self, *args, **kwargs):
|
|
|
|
|
idx = self.dialog.user_list.GetSelection()
|
|
|
|
|
if idx < 0 or idx >= len(self.users):
|
|
|
|
|
return
|
|
|
|
|
handle = self.users[idx].get("handle")
|
|
|
|
|
if not handle:
|
|
|
|
|
return
|
|
|
|
|
user_actions_controller.userActions(self.session, [handle])
|
|
|
|
|
|
|
|
|
|
def on_details(self, *args, **kwargs):
|
|
|
|
|
idx = self.dialog.user_list.GetSelection()
|
|
|
|
|
if idx < 0 or idx >= len(self.users):
|
|
|
|
|
return
|
|
|
|
|
user_ident = self.users[idx].get("did") or self.users[idx].get("handle")
|
|
|
|
|
if not user_ident:
|
|
|
|
|
return
|
|
|
|
|
dlg = ShowUserProfileDialog(self.dialog, self.session, user_ident)
|
|
|
|
|
dlg.ShowModal()
|
|
|
|
|
dlg.Destroy()
|
2026-02-01 13:17:49 +01:00
|
|
|
|
|
|
|
|
def _enable_pagination(self):
|
|
|
|
|
if not self._fetch_fn:
|
|
|
|
|
return
|
|
|
|
|
if not self._cursor:
|
|
|
|
|
return
|
|
|
|
|
self.dialog.load_more_button.Show()
|
|
|
|
|
widgetUtils.connect_event(self.dialog.load_more_button, widgetUtils.BUTTON_PRESSED, self.load_more)
|
|
|
|
|
self.dialog.Layout()
|
|
|
|
|
|
|
|
|
|
def load_more(self, *args, **kwargs):
|
|
|
|
|
if not self._fetch_fn:
|
|
|
|
|
return
|
|
|
|
|
if not self._cursor:
|
|
|
|
|
self.dialog.load_more_button.Disable()
|
|
|
|
|
return
|
|
|
|
|
try:
|
|
|
|
|
res = self._fetch_fn(cursor=self._cursor)
|
|
|
|
|
items = res.get("items", []) if isinstance(res, dict) else []
|
|
|
|
|
self._cursor = res.get("cursor") if isinstance(res, dict) else None
|
|
|
|
|
new_users = self.process_users(items)
|
|
|
|
|
if not new_users:
|
|
|
|
|
self.dialog.load_more_button.Disable()
|
|
|
|
|
return
|
|
|
|
|
self.users.extend(new_users)
|
|
|
|
|
self.dialog.add_users([u.get("display_name") for u in new_users])
|
|
|
|
|
if not self._cursor:
|
|
|
|
|
self.dialog.load_more_button.Disable()
|
|
|
|
|
except Exception:
|
|
|
|
|
self.dialog.load_more_button.Disable()
|