This commit is contained in:
Jesús Pavón Abián
2026-01-11 20:13:56 +01:00
parent 9d9d86160d
commit 932e44a9c9
391 changed files with 120828 additions and 1090 deletions
+134 -378
View File
@@ -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()
+5 -5
View File
@@ -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())
+3 -3
View File
@@ -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())
+2 -2
View File
@@ -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())
+11
View File
@@ -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)
+115 -111
View File
@@ -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
View 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()
+2 -2
View File
@@ -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()