2025-05-30 16:16:21 +00:00
# -*- coding: utf-8 -*-
import wx
import logging
2026-01-11 20:13:56 +01:00
import languageHandler
import builtins
2026-02-01 14:48:00 +01:00
import requests
from io import BytesIO
2026-01-11 20:13:56 +01:00
from threading import Thread
2026-02-01 14:48:00 +01:00
from pubsub import pub
2025-05-30 16:16:21 +00:00
2026-01-11 20:13:56 +01:00
_ = getattr ( builtins , "_" , lambda s : s )
2025-05-30 16:16:21 +00:00
logger = logging . getLogger ( __name__ )
2026-02-01 14:48:00 +01:00
def returnTrue ():
return True
2025-05-30 16:16:21 +00:00
class ShowUserProfileDialog ( wx . Dialog ):
def __init__ ( self , parent , session , user_identifier : str ): # user_identifier can be DID or handle
super ( ShowUserProfileDialog , self ) . __init__ ( parent , title = _ ( "User Profile" ), style = wx . DEFAULT_DIALOG_STYLE | wx . RESIZE_BORDER )
self . session = session
self . user_identifier = user_identifier
self . profile_data = None # Will store the formatted profile dict
self . target_user_did = None # Will store the resolved DID of the profile being viewed
self . _init_ui ()
self . SetMinSize (( 400 , 300 ))
self . CentreOnParent ()
2026-01-11 20:13:56 +01:00
Thread ( target = self . load_profile_data , daemon = True ) . start ()
2025-05-30 16:16:21 +00:00
def _init_ui ( self ):
2026-02-01 14:48:00 +01:00
self . panel = wx . Panel ( self )
2025-05-30 16:16:21 +00:00
main_sizer = wx . BoxSizer ( wx . VERTICAL )
# Profile Info Section (StaticTexts for labels and values)
self . info_grid_sizer = wx . FlexGridSizer ( cols = 2 , vgap = 5 , hgap = 5 )
self . info_grid_sizer . AddGrowableCol ( 1 , 1 )
2026-02-01 14:48:00 +01:00
# Basic text fields (name, handle, bio)
2025-05-30 16:16:21 +00:00
fields = [
2026-02-01 14:48:00 +01:00
( _ ( "&Name:" ), "displayName" ), ( _ ( "&Handle:" ), "handle" ),
2026-01-11 20:13:56 +01:00
( _ ( "&Bio:" ), "description" )
2025-05-30 16:16:21 +00:00
]
self . profile_field_ctrls = {}
for label_text , data_key in fields :
2026-02-01 14:48:00 +01:00
lbl = wx . StaticText ( self . panel , label = label_text )
2026-01-11 20:13:56 +01:00
style = wx . TE_READONLY | wx . TE_PROCESS_TAB
if data_key == "description" :
style |= wx . TE_MULTILINE
else :
style |= wx . BORDER_NONE
2026-02-01 14:48:00 +01:00
val_ctrl = wx . TextCtrl ( self . panel , style = style )
if data_key != "description" :
val_ctrl . SetBackgroundColour ( self . panel . GetBackgroundColour ())
val_ctrl . AcceptsFocusFromKeyboard = returnTrue
2025-05-30 16:16:21 +00:00
self . info_grid_sizer . Add ( lbl , 0 , wx . ALIGN_RIGHT | wx . ALIGN_TOP | wx . ALL , 2 )
self . info_grid_sizer . Add ( val_ctrl , 1 , wx . EXPAND | wx . ALL , 2 )
self . profile_field_ctrls [ data_key ] = val_ctrl
2026-02-01 14:48:00 +01:00
# Banner image
bannerLabel = wx . StaticText ( self . panel , label = _ ( "Banner:" ))
self . bannerImage = wx . StaticBitmap ( self . panel )
self . bannerImage . AcceptsFocusFromKeyboard = returnTrue
self . info_grid_sizer . Add ( bannerLabel , 0 , wx . ALIGN_RIGHT | wx . ALIGN_TOP | wx . ALL , 2 )
self . info_grid_sizer . Add ( self . bannerImage , 0 , wx . ALL , 2 )
2025-05-30 16:16:21 +00:00
2026-02-01 14:48:00 +01:00
# Avatar image
avatarLabel = wx . StaticText ( self . panel , label = _ ( "Avatar:" ))
self . avatarImage = wx . StaticBitmap ( self . panel )
self . avatarImage . AcceptsFocusFromKeyboard = returnTrue
self . info_grid_sizer . Add ( avatarLabel , 0 , wx . ALIGN_RIGHT | wx . ALIGN_TOP | wx . ALL , 2 )
self . info_grid_sizer . Add ( self . avatarImage , 0 , wx . ALL , 2 )
2025-05-30 16:16:21 +00:00
main_sizer . Add ( self . info_grid_sizer , 1 , wx . EXPAND | wx . ALL , 10 )
2026-02-01 14:48:00 +01:00
# Timeline buttons (like Mastodon - with counters)
timeline_sizer = wx . BoxSizer ( wx . HORIZONTAL )
self . posts_btn = wx . Button ( self . panel , label = _ ( "0 pos&ts" ))
self . posts_btn . Bind ( wx . EVT_BUTTON , self . onPosts )
timeline_sizer . Add ( self . posts_btn , 0 , wx . ALL , 3 )
self . following_btn = wx . Button ( self . panel , label = _ ( "0 &following" ))
self . following_btn . Bind ( wx . EVT_BUTTON , self . onFollowing )
timeline_sizer . Add ( self . following_btn , 0 , wx . ALL , 3 )
self . followers_btn = wx . Button ( self . panel , label = _ ( "0 fo&llowers" ))
self . followers_btn . Bind ( wx . EVT_BUTTON , self . onFollowers )
timeline_sizer . Add ( self . followers_btn , 0 , wx . ALL , 3 )
main_sizer . Add ( timeline_sizer , 0 , wx . ALIGN_CENTER | wx . TOP | wx . BOTTOM , 5 )
2025-05-30 16:16:21 +00:00
# Action Buttons
actions_sizer = wx . BoxSizer ( wx . HORIZONTAL )
2026-02-01 14:48:00 +01:00
self . follow_btn = wx . Button ( self . panel , label = _ ( "&Follow" ))
self . unfollow_btn = wx . Button ( self . panel , label = _ ( "U&nfollow" ))
self . mute_btn = wx . Button ( self . panel , label = _ ( "&Mute" ))
self . unmute_btn = wx . Button ( self . panel , label = _ ( "Unmu&te" ))
self . block_btn = wx . Button ( self . panel , label = _ ( "&Block" ))
self . unblock_btn = wx . Button ( self . panel , label = _ ( "Unbl&ock" ))
2025-05-30 16:16:21 +00:00
self . follow_btn . Bind ( wx . EVT_BUTTON , lambda evt , cmd = "follow_user" : self . on_user_action ( evt , cmd ))
self . unfollow_btn . Bind ( wx . EVT_BUTTON , lambda evt , cmd = "unfollow_user" : self . on_user_action ( evt , cmd ))
self . mute_btn . Bind ( wx . EVT_BUTTON , lambda evt , cmd = "mute_user" : self . on_user_action ( evt , cmd ))
self . unmute_btn . Bind ( wx . EVT_BUTTON , lambda evt , cmd = "unmute_user" : self . on_user_action ( evt , cmd ))
self . block_btn . Bind ( wx . EVT_BUTTON , lambda evt , cmd = "block_user" : self . on_user_action ( evt , cmd ))
self . unblock_btn . Bind ( wx . EVT_BUTTON , lambda evt , cmd = "unblock_user" : self . on_user_action ( evt , cmd ))
actions_sizer . Add ( self . follow_btn , 0 , wx . ALL , 3 )
actions_sizer . Add ( self . unfollow_btn , 0 , wx . ALL , 3 )
actions_sizer . Add ( self . mute_btn , 0 , wx . ALL , 3 )
actions_sizer . Add ( self . unmute_btn , 0 , wx . ALL , 3 )
actions_sizer . Add ( self . block_btn , 0 , wx . ALL , 3 )
2026-02-01 14:48:00 +01:00
actions_sizer . Add ( self . unblock_btn , 0 , wx . ALL , 3 )
2025-05-30 16:16:21 +00:00
main_sizer . Add ( actions_sizer , 0 , wx . ALIGN_CENTER | wx . TOP | wx . BOTTOM , 10 )
# Close Button
2026-02-01 14:48:00 +01:00
close_btn = wx . Button ( self . panel , wx . ID_CANCEL , _ ( "&Close" ))
close_btn . SetDefault ()
2025-05-30 16:16:21 +00:00
main_sizer . Add ( close_btn , 0 , wx . ALIGN_RIGHT | wx . ALL , 10 )
2026-01-11 20:13:56 +01:00
self . SetEscapeId ( close_btn . GetId ())
2025-05-30 16:16:21 +00:00
2026-02-01 14:48:00 +01:00
self . panel . SetSizer ( main_sizer )
self . Fit ()
2025-05-30 16:16:21 +00:00
2026-01-11 20:13:56 +01:00
def load_profile_data ( self ):
wx . CallAfter ( self . SetStatusText , _ ( "Loading profile..." ))
2025-05-30 16:16:21 +00:00
for ctrl in self . profile_field_ctrls . values ():
2026-01-11 20:13:56 +01:00
wx . CallAfter ( ctrl . SetValue , _ ( "Loading..." ))
2025-05-30 16:16:21 +00:00
# Initially hide all action buttons until state is known
2026-01-11 20:13:56 +01:00
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 )
2025-05-30 16:16:21 +00:00
try :
2026-01-11 20:13:56 +01:00
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 )
2025-05-30 16:16:21 +00:00
except Exception as e :
logger . error ( f "Error loading profile for { self . user_identifier } : { e } " , exc_info = True )
2026-01-11 20:13:56 +01:00
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 :
2025-05-30 16:16:21 +00:00
for ctrl in self . profile_field_ctrls . values ():
2026-01-11 20:13:56 +01:00
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 ()
2025-05-30 16:16:21 +00:00
def update_ui_fields ( self ):
if not self . profile_data :
return
for key , ctrl in self . profile_field_ctrls . items ():
2026-02-01 14:48:00 +01:00
value = self . profile_data . get ( key )
if key == "description" and value :
ctrl . SetMinSize (( - 1 , 60 ))
2025-05-30 16:16:21 +00:00
if isinstance ( value , ( int , float )):
ctrl . SetValue ( str ( value ))
2026-02-01 14:48:00 +01:00
else :
2025-05-30 16:16:21 +00:00
ctrl . SetValue ( value or _ ( "N/A" ))
2026-02-01 14:48:00 +01:00
# Update timeline buttons with counts
posts_count = self . profile_data . get ( "postsCount" ) or 0
followers_count = self . profile_data . get ( "followersCount" ) or 0
following_count = self . profile_data . get ( "followsCount" ) or 0
self . posts_btn . SetLabel ( _ ( " {count} pos&ts. Click to open posts timeline" ) . format ( count = posts_count ))
self . followers_btn . SetLabel ( _ ( " {count} fo&llowers. Click to open followers timeline" ) . format ( count = followers_count ))
self . following_btn . SetLabel ( _ ( " {count} &following. Click to open following timeline" ) . format ( count = following_count ))
# Start image download in background thread
Thread ( target = self . _download_images , daemon = True ) . start ()
2025-05-30 16:16:21 +00:00
self . Layout ()
2026-02-01 14:48:00 +01:00
def _download_images ( self ):
"""Downloads avatar and banner images from Bluesky server."""
avatar_url = self . profile_data . get ( "avatar" ) if self . profile_data else None
banner_url = self . profile_data . get ( "banner" ) if self . profile_data else None
avatar_bytes = None
banner_bytes = None
try :
if banner_url :
resp = requests . get ( banner_url , timeout = 10 )
if resp . status_code == 200 :
banner_bytes = resp . content
except Exception as e :
logger . debug ( f "Failed to download banner: { e } " )
try :
if avatar_url :
resp = requests . get ( avatar_url , timeout = 10 )
if resp . status_code == 200 :
avatar_bytes = resp . content
except Exception as e :
logger . debug ( f "Failed to download avatar: { e } " )
wx . CallAfter ( self . _draw_images , banner_bytes , avatar_bytes )
def _draw_images ( self , banner_bytes , avatar_bytes ):
"""Draws downloaded images on the bitmap controls."""
try :
if banner_bytes :
banner_image = wx . Image ( BytesIO ( banner_bytes ), wx . BITMAP_TYPE_ANY )
banner_image . Rescale ( 300 , 100 , wx . IMAGE_QUALITY_HIGH )
self . bannerImage . SetBitmap ( banner_image . ConvertToBitmap ())
if avatar_bytes :
avatar_image = wx . Image ( BytesIO ( avatar_bytes ), wx . BITMAP_TYPE_ANY )
avatar_image . Rescale ( 150 , 150 , wx . IMAGE_QUALITY_HIGH )
self . avatarImage . SetBitmap ( avatar_image . ConvertToBitmap ())
self . Layout ()
self . Fit ()
except Exception as e :
logger . debug ( f "Failed to draw images: { e } " )
def onPosts ( self , * args ):
"""Open this user's posts timeline."""
if self . profile_data :
pub . sendMessage ( 'execute-action' , action = 'openPostTimeline' , kwargs = dict ( user = self . profile_data ))
def onFollowing ( self , * args ):
"""Open following timeline for this user."""
if self . profile_data :
pub . sendMessage ( 'execute-action' , action = 'openFollowingTimeline' , kwargs = dict ( user = self . profile_data ))
def onFollowers ( self , * args ):
"""Open followers timeline for this user."""
if self . profile_data :
pub . sendMessage ( 'execute-action' , action = 'openFollowersTimeline' , kwargs = dict ( user = self . profile_data ))
2025-05-30 16:16:21 +00:00
def update_action_buttons_state ( self ):
2026-01-11 20:13:56 +01:00
if not self . profile_data or not self . target_user_did or self . target_user_did == self . _get_own_did ():
2025-05-30 16:16:21 +00:00
self . follow_btn . Hide ()
self . unfollow_btn . Hide ()
self . mute_btn . Hide ()
self . unmute_btn . Hide ()
self . block_btn . Hide ()
self . unblock_btn . Hide ()
self . Layout ()
return
viewer_state = self . profile_data . get ( "viewer" , {})
is_following = bool ( viewer_state . get ( "following" ))
is_muted = bool ( viewer_state . get ( "muted" ))
# 'blocking' in viewer state is the URI of *our* block record, if we are blocking them.
is_blocking_them = bool ( viewer_state . get ( "blocking" ))
# 'blockedBy' means *they* are blocking us. If true, most actions might fail or be hidden.
is_blocked_by_them = bool ( viewer_state . get ( "blockedBy" ))
if is_blocked_by_them : # If they block us, we can't do much.
self . follow_btn . Hide ()
self . unfollow_btn . Hide ()
self . mute_btn . Hide ()
self . unmute_btn . Hide ()
# We can still block them, or unblock them if we previously did.
self . block_btn . Show ( not is_blocking_them )
self . unblock_btn . Show ( is_blocking_them )
self . Layout ()
return
self . follow_btn . Show ( not is_following and not is_blocking_them )
self . unfollow_btn . Show ( is_following and not is_blocking_them )
self . mute_btn . Show ( not is_muted and not is_blocking_them )
self . unmute_btn . Show ( is_muted and not is_blocking_them )
self . block_btn . Show ( not is_blocking_them ) # Show block if we are not currently blocking them (even if they block us)
self . unblock_btn . Show ( is_blocking_them ) # Show unblock if we are currently blocking them
self . Layout () # Refresh sizer to show/hide buttons correctly
def on_user_action ( self , event , command : str ):
if not self . target_user_did : # Should be set by load_profile_data
wx . MessageBox ( _ ( "User identifier (DID) not available for this action." ), _ ( "Error" ), wx . OK | wx . ICON_ERROR )
return
# Confirmation for sensitive actions
confirmation_map = {
"unfollow_user" : _ ( "Are you sure you want to unfollow @ {handle} ?" ) . format ( handle = self . profile_data . get ( "handle" , "this user" )),
"block_user" : _ ( "Are you sure you want to block @ {handle} ? This will prevent them from interacting with you and hide their content." ) . format ( handle = self . profile_data . get ( "handle" , "this user" )),
# Unblock usually doesn't need confirmation, but can be added if desired.
}
if command in confirmation_map :
dlg = wx . MessageDialog ( self , confirmation_map [ command ], _ ( "Confirm Action" ), wx . YES_NO | wx . ICON_QUESTION )
if dlg . ShowModal () != wx . ID_YES :
dlg . Destroy ()
return
dlg . Destroy ()
2026-01-11 20:13:56 +01:00
wx . BeginBusyCursor ()
self . SetStatusText ( _ ( "Performing action: {action} ..." ) . format ( action = command ))
action_button = event . GetEventObject ()
if action_button :
action_button . Disable ()
2025-05-30 16:16:21 +00:00
2026-01-11 20:13:56 +01:00
try :
2026-02-01 14:48:00 +01:00
ok = False
if command == "follow_user" and hasattr ( self . session , "follow_user" ):
ok = self . session . follow_user ( self . target_user_did )
elif command == "unfollow_user" and hasattr ( self . session , "unfollow_user" ):
viewer_state = self . profile_data . get ( "viewer" , {}) if self . profile_data else {}
follow_uri = viewer_state . get ( "following" )
if follow_uri :
ok = self . session . unfollow_user ( follow_uri )
else :
raise RuntimeError ( _ ( "Follow information not available." ))
elif command == "mute_user" and hasattr ( self . session , "mute_user" ):
ok = self . session . mute_user ( self . target_user_did )
elif command == "unmute_user" and hasattr ( self . session , "unmute_user" ):
ok = self . session . unmute_user ( self . target_user_did )
elif command == "block_user" and hasattr ( self . session , "block_user" ):
2026-01-11 20:13:56 +01:00
ok = self . session . block_user ( self . target_user_did )
elif command == "unblock_user" and hasattr ( self . session , "unblock_user" ):
viewer_state = self . profile_data . get ( "viewer" , {}) if self . profile_data else {}
block_uri = viewer_state . get ( "blocking" )
if not block_uri :
raise RuntimeError ( _ ( "Block information not available." ))
ok = self . session . unblock_user ( block_uri )
else :
raise RuntimeError ( _ ( "This action is not supported yet." ))
2025-05-30 16:16:21 +00:00
2026-02-01 14:48:00 +01:00
if not ok :
raise RuntimeError ( _ ( "Action failed." ))
2026-01-11 20:13:56 +01:00
wx . EndBusyCursor ()
wx . MessageBox ( _ ( "Action completed." ), _ ( "Success" ), wx . OK | wx . ICON_INFORMATION , self )
2026-02-01 14:48:00 +01:00
# Reload profile data in a new thread
Thread ( target = self . load_profile_data , daemon = True ) . start ()
2026-01-11 20:13:56 +01:00
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 )
2025-05-30 16:16:21 +00:00
2026-01-11 20:13:56 +01:00
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
2025-05-30 16:16:21 +00:00
2026-01-11 20:13:56 +01:00
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 )
2025-05-30 16:16:21 +00:00
2026-02-01 12:49:33 +01:00
def get_count ( * keys ):
for k in keys :
val = g ( profile_model , k )
if val is not None :
return val
return None
2026-01-11 20:13:56 +01:00
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" ),
2026-02-01 12:49:33 +01:00
"followersCount" : get_count ( "followersCount" , "followers_count" ),
"followsCount" : get_count ( "followsCount" , "follows_count" , "followingCount" , "following_count" ),
"postsCount" : get_count ( "postsCount" , "posts_count" ),
2026-01-11 20:13:56 +01:00
"viewer" : g ( profile_model , "viewer" ) or {},
}
2025-05-30 16:16:21 +00:00
def SetStatusText ( self , text ): # Simple status text for dialog title
self . SetTitle ( f " { _ ( 'User Profile' ) } - { text } " )