From 7399ac46d4582db881a31ddb71f3c4032b5b0f6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Pav=C3=B3n=20Abi=C3=A1n?= Date: Fri, 7 Nov 2025 09:24:02 +0100 Subject: [PATCH] Commit --- requirements.txt | 7 +- src/controller/atprotosocial/handler.py | 25 +++ src/controller/mainController.py | 72 ++++++-- src/sessions/atprotosocial/session.py | 107 ++++++++++- src/wxUI/buffers/atprotosocial/panels.py | 169 ++++++++++++++++-- .../dialogs/atprotosocial/configuration.py | 33 ++++ 6 files changed, 374 insertions(+), 39 deletions(-) create mode 100644 src/wxUI/dialogs/atprotosocial/configuration.py diff --git a/requirements.txt b/requirements.txt index 8843fd70..8b67e553 100644 --- a/requirements.txt +++ b/requirements.txt @@ -53,6 +53,7 @@ types-python-dateutil==2.9.0.20250516 urllib3==2.4.0 win-inet-pton==1.1.0 winpaths==0.2 -wxPython==4.2.3 -youtube-dl==2021.12.17 -zipp==3.21.0 \ No newline at end of file +wxPython==4.2.3 +youtube-dl==2021.12.17 +zipp==3.21.0 +atproto>=0.0.45 diff --git a/src/controller/atprotosocial/handler.py b/src/controller/atprotosocial/handler.py index 69388545..eb0b79d0 100644 --- a/src/controller/atprotosocial/handler.py +++ b/src/controller/atprotosocial/handler.py @@ -61,6 +61,31 @@ class Handler: except Exception: pass + def account_settings(self, buffer, controller): + """Open a minimal account settings dialog for Bluesky.""" + try: + current_mode = None + try: + current_mode = buffer.session.settings["general"].get("boost_mode") + except Exception: + current_mode = None + ask_default = True if current_mode in (None, "ask") else False + + from wxUI.dialogs.atprotosocial.configuration import AccountSettingsDialog + dlg = AccountSettingsDialog(controller.view, ask_before_boost=ask_default) + resp = dlg.ShowModal() + if resp == wx.ID_OK: + vals = dlg.get_values() + boost_mode = "ask" if vals.get("ask_before_boost") else "direct" + try: + buffer.session.settings["general"]["boost_mode"] = boost_mode + buffer.session.settings.write() + except Exception: + logger.exception("Failed to persist Bluesky boost_mode setting") + dlg.Destroy() + except Exception: + logger.exception("Error opening Bluesky account settings dialog") + 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 diff --git a/src/controller/mainController.py b/src/controller/mainController.py index d3fc2660..371a1702 100644 --- a/src/controller/mainController.py +++ b/src/controller/mainController.py @@ -788,14 +788,33 @@ class Controller(object): text = dlg.GetValue().strip() dlg.Destroy() try: - uri = session.send_message(text, quote_uri=item_uri) - if uri: - output.speak(_("Quote posted."), True) + if text: + uri = session.send_message(text, quote_uri=item_uri) + if uri: + output.speak(_("Quote posted."), True) + else: + output.speak(_("Failed to send quote."), True) else: - output.speak(_("Failed to send quote."), True) + # Confirm repost (share) depending on preference (boost_mode) + ask = True + try: + ask = session.settings["general"].get("boost_mode", "ask") == "ask" + except Exception: + ask = True + if ask: + confirm = wx.MessageDialog(None, _("Would you like to share this post?"), _("Boost"), wx.YES_NO|wx.ICON_QUESTION) + if confirm.ShowModal() != wx.ID_YES: + confirm.Destroy() + return + confirm.Destroy() + r_uri = session.repost(item_uri) + if r_uri: + output.speak(_("Post shared."), True) + else: + output.speak(_("Failed to share post."), True) except Exception: - log.exception("Error sending Bluesky quote (invisible)") - output.speak(_("An error occurred while posting the quote."), True) + log.exception("Error sharing/quoting Bluesky post (invisible)") + output.speak(_("An error occurred while sharing the post."), True) else: dlg.Destroy() return @@ -806,19 +825,38 @@ class Controller(object): text, files, cw_text, langs = dlg.get_payload() dlg.Destroy() try: - uri = session.send_message(text, files=files, cw_text=cw_text, is_sensitive=bool(cw_text), languages=langs, quote_uri=item_uri) - if uri: - output.speak(_("Quote posted."), True) - try: - if hasattr(buffer, "start_stream"): - buffer.start_stream(mandatory=False, play_sound=False) - except Exception: - pass + if text or files or cw_text: + uri = session.send_message(text, files=files, cw_text=cw_text, is_sensitive=bool(cw_text), languages=langs, quote_uri=item_uri) + if uri: + output.speak(_("Quote posted."), True) + try: + if hasattr(buffer, "start_stream"): + buffer.start_stream(mandatory=False, play_sound=False) + except Exception: + pass + else: + output.speak(_("Failed to send quote."), True) else: - output.speak(_("Failed to send quote."), True) + # Confirm repost without comment depending on preference + ask = True + try: + ask = session.settings["general"].get("boost_mode", "ask") == "ask" + except Exception: + ask = True + if ask: + confirm = wx.MessageDialog(self.view, _("Would you like to share this post?"), _("Boost"), wx.YES_NO|wx.ICON_QUESTION) + if confirm.ShowModal() != wx.ID_YES: + confirm.Destroy() + return + confirm.Destroy() + r_uri = session.repost(item_uri) + if r_uri: + output.speak(_("Post shared."), True) + else: + output.speak(_("Failed to share post."), True) except Exception: - log.exception("Error sending Bluesky quote (dialog)") - output.speak(_("An error occurred while posting the quote."), True) + log.exception("Error sharing/quoting Bluesky post (dialog)") + output.speak(_("An error occurred while sharing the post."), True) else: dlg.Destroy() return diff --git a/src/sessions/atprotosocial/session.py b/src/sessions/atprotosocial/session.py index d94fe788..e643537d 100644 --- a/src/sessions/atprotosocial/session.py +++ b/src/sessions/atprotosocial/session.py @@ -77,6 +77,12 @@ class Session(base.baseSession): # Ensure db exists (can be set to None on logout paths) if not isinstance(self.db, dict): self.db = {} + # Ensure general settings have a default for boost confirmations like Mastodon + try: + if "general" in self.settings and self.settings["general"].get("boost_mode") is None: + self.settings["general"]["boost_mode"] = "ask" + except Exception: + pass api = self._ensure_client() # Prefer resuming session if we have one if session_string: @@ -256,12 +262,61 @@ class Session(base.baseSession): "images": embed_images, } - # Reply-to handling (sets parent/root strong refs) + # Helper: normalize various incoming identifiers to an at:// URI + def _normalize_to_uri(identifier: str) -> str | None: + try: + if not isinstance(identifier, str): + return None + if identifier.startswith("at://"): + return identifier + if "bsky.app/profile/" in identifier and "/post/" in identifier: + # Accept full web URL and try to resolve via get_post_thread below + return identifier + # Accept bare rkey case by constructing a guess using own handle + handle = self.db.get("user_name") or self.settings["atprotosocial"].get("handle") + did = self.db.get("user_id") or self.settings["atprotosocial"].get("did") + if handle and did and len(identifier) in (13, 14, 15): + # rkey length is typically ~13 chars base32 + return f"at://{did}/app.bsky.feed.post/{identifier}" + except Exception: + pass + return None + + # Reply-to handling (sets correct root/parent strong refs) if reply_to: - parent_ref = _get_strong_ref(reply_to) + # Resolve to proper at:// uri when possible + reply_uri = _normalize_to_uri(reply_to) or reply_to + parent_ref = _get_strong_ref(reply_uri) + root_ref = parent_ref + # Try to fetch thread to find actual root for deep replies + try: + # atproto SDK usually exposes get_post_thread + thread_res = None + try: + thread_res = api.app.bsky.feed.get_post_thread({"uri": reply_uri}) + except Exception: + # Try typed model call variant if available + from atproto import models as at_models # type: ignore + params = at_models.AppBskyFeedGetPostThread.Params(uri=reply_uri) + thread_res = api.app.bsky.feed.get_post_thread(params) + thread = getattr(thread_res, "thread", None) + # Walk to the root if present + node = thread + while node and getattr(node, "parent", None): + node = getattr(node, "parent") + root_uri = getattr(node, "post", None) + if root_uri: + root_uri = getattr(root_uri, "uri", None) + if root_uri and isinstance(root_uri, str): + maybe_root = _get_strong_ref(root_uri) + if maybe_root: + root_ref = maybe_root + except Exception: + # If anything fails, keep parent as root for a simple two-level reply + pass if parent_ref: record["reply"] = { - "root": parent_ref, + "root": root_ref or parent_ref, "parent": parent_ref, } @@ -291,3 +346,49 @@ class Session(base.baseSession): log.exception("Error sending Bluesky post") output.speak(_("An error occurred while posting to Bluesky."), True) return None + + def repost(self, post_uri: str, post_cid: str | None = None) -> str | None: + """Create a simple repost of a given post. Returns URI of the repost record or None.""" + if not self.logged: + raise Exceptions.NotLoggedSessionError("You are not logged in yet.") + try: + api = self._ensure_client() + + def _get_strong_ref(uri: str): + try: + posts_res = api.app.bsky.feed.get_posts({"uris": [uri]}) + posts = getattr(posts_res, "posts", None) or [] + except Exception: + try: + posts_res = api.app.bsky.feed.get_posts(uris=[uri]) + posts = getattr(posts_res, "posts", None) or [] + except Exception: + posts = [] + if posts: + post0 = posts[0] + s_uri = getattr(post0, "uri", uri) + s_cid = getattr(post0, "cid", None) or (post0.get("cid") if isinstance(post0, dict) else None) + if s_cid: + return {"uri": s_uri, "cid": s_cid} + return None + + if not post_cid: + strong = _get_strong_ref(post_uri) + if not strong: + return None + post_uri = strong["uri"] + post_cid = strong["cid"] + + out = api.com.atproto.repo.create_record({ + "repo": api.me.did, + "collection": "app.bsky.feed.repost", + "record": { + "$type": "app.bsky.feed.repost", + "subject": {"uri": post_uri, "cid": post_cid}, + "createdAt": getattr(api, "get_current_time_iso", lambda: None)() or None, + }, + }) + return getattr(out, "uri", None) + except Exception: + log.exception("Error creating Bluesky repost record") + return None diff --git a/src/wxUI/buffers/atprotosocial/panels.py b/src/wxUI/buffers/atprotosocial/panels.py index 03edd948..59e66002 100644 --- a/src/wxUI/buffers/atprotosocial/panels.py +++ b/src/wxUI/buffers/atprotosocial/panels.py @@ -5,6 +5,8 @@ import logging import wx import config from mysc.repeating_timer import RepeatingTimer +import arrow +import arrow from datetime import datetime from multiplatform_widgets import widgets @@ -32,7 +34,7 @@ class ATProtoSocialHomeTimelinePanel(object): 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, text, indexed_at} + self.items = [] # list of dicts: {uri, author, handle, display_name, text, indexed_at} self.cursor = None self._auto_timer = None @@ -47,8 +49,14 @@ class ATProtoSocialHomeTimelinePanel(object): # 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) - res = api.app.bsky.feed.get_timeline(params) + # Home: algorithmic/default timeline + try: + params = at_models.AppBskyFeedGetTimeline.Params(limit=count) + res = api.app.bsky.feed.get_timeline(params) + except Exception: + # Some SDKs may require explicit algorithm for home; try behavioral + params = at_models.AppBskyFeedGetTimeline.Params(limit=count, algorithm="behavioral") + res = api.app.bsky.feed.get_timeline(params) except Exception: # Fallback to plain dict params if typed models unavailable res = api.app.bsky.feed.get_timeline({"limit": count}) @@ -59,17 +67,27 @@ class ATProtoSocialHomeTimelinePanel(object): 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) - self.items.append({ + item = { "uri": getattr(post, "uri", ""), - "author": handle, + "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: @@ -96,26 +114,52 @@ class ATProtoSocialHomeTimelinePanel(object): 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": handle, + "author": display_name or handle, + "handle": handle, + "display_name": display_name, "text": text, "indexed_at": indexed_at, }) if not new_items: return 0 - self.items.extend(new_items) + 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 ATProto + 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() @@ -124,14 +168,33 @@ class ATProtoSocialHomeTimelinePanel(object): dt = "" if it.get("indexed_at"): try: - # indexed_at is ISO format; show HH:MM or date - dt = str(it["indexed_at"])[:16].replace("T", " ") + # 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: - dt = "" + 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] + "..." - self.buffer.list.insert_item(False, it.get("author", ""), text, dt) + # 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): @@ -151,6 +214,28 @@ class ATProtoSocialHomeTimelinePanel(object): 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: @@ -215,7 +300,8 @@ class ATProtoSocialFollowingTimelinePanel(ATProtoSocialHomeTimelinePanel): count = 40 try: api = self.session._ensure_client() - # Use plain dict params to ensure algorithm is passed regardless of SDK models version + # 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": "reverse-chronological"}) feed = getattr(res, "feed", []) self.cursor = getattr(res, "cursor", None) @@ -228,13 +314,21 @@ class ATProtoSocialFollowingTimelinePanel(ATProtoSocialHomeTimelinePanel): 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) - self.items.append({ + item = { "uri": getattr(post, "uri", ""), - "author": handle, + "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: @@ -242,3 +336,46 @@ class ATProtoSocialFollowingTimelinePanel(ATProtoSocialHomeTimelinePanel): 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() + # Pagination via reverse-chronological algorithm on get_timeline + res = api.app.bsky.feed.get_timeline({"limit": 40, "cursor": self.cursor, "algorithm": "reverse-chronological"}) + 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 + diff --git a/src/wxUI/dialogs/atprotosocial/configuration.py b/src/wxUI/dialogs/atprotosocial/configuration.py new file mode 100644 index 00000000..0fff820a --- /dev/null +++ b/src/wxUI/dialogs/atprotosocial/configuration.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8 -*- +import wx +import languageHandler + + +class AccountSettingsDialog(wx.Dialog): + def __init__(self, parent=None, ask_before_boost=True): + super(AccountSettingsDialog, self).__init__(parent, title=_("Bluesky Account Settings")) + panel = wx.Panel(self) + + sizer = wx.BoxSizer(wx.VERTICAL) + + # Ask before boost/share + self.ask_before_boost = wx.CheckBox(panel, wx.ID_ANY, _("Ask confirmation before sharing a post")) + self.ask_before_boost.SetValue(bool(ask_before_boost)) + sizer.Add(self.ask_before_boost, 0, wx.ALL, 8) + + # Buttons + btn_sizer = self.CreateSeparatedButtonSizer(wx.OK | wx.CANCEL) + + panel.SetSizer(sizer) + + main = wx.BoxSizer(wx.VERTICAL) + main.Add(panel, 1, wx.EXPAND | wx.ALL, 10) + if btn_sizer: + main.Add(btn_sizer, 0, wx.EXPAND | wx.ALL, 10) + self.SetSizerAndFit(main) + + def get_values(self): + return { + "ask_before_boost": self.ask_before_boost.GetValue(), + } +