This commit is contained in:
Jesús Pavón Abián
2025-11-07 09:24:02 +01:00
parent 9124476ce0
commit 7399ac46d4
6 changed files with 374 additions and 39 deletions

View File

@@ -53,6 +53,7 @@ types-python-dateutil==2.9.0.20250516
urllib3==2.4.0 urllib3==2.4.0
win-inet-pton==1.1.0 win-inet-pton==1.1.0
winpaths==0.2 winpaths==0.2
wxPython==4.2.3 wxPython==4.2.3
youtube-dl==2021.12.17 youtube-dl==2021.12.17
zipp==3.21.0 zipp==3.21.0
atproto>=0.0.45

View File

@@ -61,6 +61,31 @@ class Handler:
except Exception: except Exception:
pass 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: 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) logger.debug("handle_action stub: %s %s %s", action_name, user_id, payload)
return None return None

View File

@@ -788,14 +788,33 @@ class Controller(object):
text = dlg.GetValue().strip() text = dlg.GetValue().strip()
dlg.Destroy() dlg.Destroy()
try: try:
uri = session.send_message(text, quote_uri=item_uri) if text:
if uri: uri = session.send_message(text, quote_uri=item_uri)
output.speak(_("Quote posted."), True) if uri:
output.speak(_("Quote posted."), True)
else:
output.speak(_("Failed to send quote."), True)
else: 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: except Exception:
log.exception("Error sending Bluesky quote (invisible)") log.exception("Error sharing/quoting Bluesky post (invisible)")
output.speak(_("An error occurred while posting the quote."), True) output.speak(_("An error occurred while sharing the post."), True)
else: else:
dlg.Destroy() dlg.Destroy()
return return
@@ -806,19 +825,38 @@ class Controller(object):
text, files, cw_text, langs = dlg.get_payload() text, files, cw_text, langs = dlg.get_payload()
dlg.Destroy() dlg.Destroy()
try: try:
uri = session.send_message(text, files=files, cw_text=cw_text, is_sensitive=bool(cw_text), languages=langs, quote_uri=item_uri) if text or files or cw_text:
if uri: uri = session.send_message(text, files=files, cw_text=cw_text, is_sensitive=bool(cw_text), languages=langs, quote_uri=item_uri)
output.speak(_("Quote posted."), True) if uri:
try: output.speak(_("Quote posted."), True)
if hasattr(buffer, "start_stream"): try:
buffer.start_stream(mandatory=False, play_sound=False) if hasattr(buffer, "start_stream"):
except Exception: buffer.start_stream(mandatory=False, play_sound=False)
pass except Exception:
pass
else:
output.speak(_("Failed to send quote."), True)
else: 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: except Exception:
log.exception("Error sending Bluesky quote (dialog)") log.exception("Error sharing/quoting Bluesky post (dialog)")
output.speak(_("An error occurred while posting the quote."), True) output.speak(_("An error occurred while sharing the post."), True)
else: else:
dlg.Destroy() dlg.Destroy()
return return

View File

@@ -77,6 +77,12 @@ class Session(base.baseSession):
# Ensure db exists (can be set to None on logout paths) # Ensure db exists (can be set to None on logout paths)
if not isinstance(self.db, dict): if not isinstance(self.db, dict):
self.db = {} 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() api = self._ensure_client()
# Prefer resuming session if we have one # Prefer resuming session if we have one
if session_string: if session_string:
@@ -256,12 +262,61 @@ class Session(base.baseSession):
"images": embed_images, "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: 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: if parent_ref:
record["reply"] = { record["reply"] = {
"root": parent_ref, "root": root_ref or parent_ref,
"parent": parent_ref, "parent": parent_ref,
} }
@@ -291,3 +346,49 @@ class Session(base.baseSession):
log.exception("Error sending Bluesky post") log.exception("Error sending Bluesky post")
output.speak(_("An error occurred while posting to Bluesky."), True) output.speak(_("An error occurred while posting to Bluesky."), True)
return None 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

View File

@@ -5,6 +5,8 @@ import logging
import wx import wx
import config import config
from mysc.repeating_timer import RepeatingTimer from mysc.repeating_timer import RepeatingTimer
import arrow
import arrow
from datetime import datetime from datetime import datetime
from multiplatform_widgets import widgets from multiplatform_widgets import widgets
@@ -32,7 +34,7 @@ class ATProtoSocialHomeTimelinePanel(object):
self.buffer.name = name self.buffer.name = name
# Ensure controller can resolve current account from the GUI panel # Ensure controller can resolve current account from the GUI panel
self.buffer.account = self.account 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.cursor = None
self._auto_timer = None self._auto_timer = None
@@ -47,8 +49,14 @@ class ATProtoSocialHomeTimelinePanel(object):
# The atproto SDK expects params, not raw kwargs # The atproto SDK expects params, not raw kwargs
try: try:
from atproto import models as at_models # type: ignore from atproto import models as at_models # type: ignore
params = at_models.AppBskyFeedGetTimeline.Params(limit=count) # Home: algorithmic/default timeline
res = api.app.bsky.feed.get_timeline(params) 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: except Exception:
# Fallback to plain dict params if typed models unavailable # Fallback to plain dict params if typed models unavailable
res = api.app.bsky.feed.get_timeline({"limit": count}) res = api.app.bsky.feed.get_timeline({"limit": count})
@@ -59,17 +67,27 @@ class ATProtoSocialHomeTimelinePanel(object):
post = getattr(it, "post", None) post = getattr(it, "post", None)
if not post: if not post:
continue continue
# No additional client-side filtering; server distinguishes timelines correctly
record = getattr(post, "record", None) record = getattr(post, "record", None)
author = getattr(post, "author", None) author = getattr(post, "author", None)
text = getattr(record, "text", "") if record else "" text = getattr(record, "text", "") if record else ""
handle = getattr(author, "handle", "") if author 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) indexed_at = getattr(post, "indexed_at", None)
self.items.append({ item = {
"uri": getattr(post, "uri", ""), "uri": getattr(post, "uri", ""),
"author": handle, "author": display_name or handle,
"handle": handle,
"display_name": display_name,
"text": text, "text": text,
"indexed_at": indexed_at, "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) self._render_list(replace=True)
return len(self.items) return len(self.items)
except Exception: except Exception:
@@ -96,26 +114,52 @@ class ATProtoSocialHomeTimelinePanel(object):
post = getattr(it, "post", None) post = getattr(it, "post", None)
if not post: if not post:
continue continue
# No additional client-side filtering
record = getattr(post, "record", None) record = getattr(post, "record", None)
author = getattr(post, "author", None) author = getattr(post, "author", None)
text = getattr(record, "text", "") if record else "" text = getattr(record, "text", "") if record else ""
handle = getattr(author, "handle", "") if author 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) indexed_at = getattr(post, "indexed_at", None)
new_items.append({ new_items.append({
"uri": getattr(post, "uri", ""), "uri": getattr(post, "uri", ""),
"author": handle, "author": display_name or handle,
"handle": handle,
"display_name": display_name,
"text": text, "text": text,
"indexed_at": indexed_at, "indexed_at": indexed_at,
}) })
if not new_items: if not new_items:
return 0 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)) self._render_list(replace=False, start=len(self.items) - len(new_items))
return len(new_items) return len(new_items)
except Exception: except Exception:
log.exception("Failed to load more Bluesky timeline items") log.exception("Failed to load more Bluesky timeline items")
return 0 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): def _render_list(self, replace: bool, start: int = 0):
if replace: if replace:
self.buffer.list.clear() self.buffer.list.clear()
@@ -124,14 +168,33 @@ class ATProtoSocialHomeTimelinePanel(object):
dt = "" dt = ""
if it.get("indexed_at"): if it.get("indexed_at"):
try: try:
# indexed_at is ISO format; show HH:MM or date # Mastodon-like date formatting: relative or full date
dt = str(it["indexed_at"])[:16].replace("T", " ") 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: except Exception:
dt = "" try:
dt = str(it["indexed_at"])[:16].replace("T", " ")
except Exception:
dt = ""
text = it.get("text", "").replace("\n", " ") text = it.get("text", "").replace("\n", " ")
if len(text) > 200: if len(text) > 200:
text = text[:197] + "..." 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 # For compatibility with controller expectations
def save_positions(self): def save_positions(self):
@@ -151,6 +214,28 @@ class ATProtoSocialHomeTimelinePanel(object):
except Exception: except Exception:
return None 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 # Auto-refresh support (polling) to simulate near real-time updates
def _periodic_refresh(self): def _periodic_refresh(self):
try: try:
@@ -215,7 +300,8 @@ class ATProtoSocialFollowingTimelinePanel(ATProtoSocialHomeTimelinePanel):
count = 40 count = 40
try: try:
api = self.session._ensure_client() 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"}) res = api.app.bsky.feed.get_timeline({"limit": count, "algorithm": "reverse-chronological"})
feed = getattr(res, "feed", []) feed = getattr(res, "feed", [])
self.cursor = getattr(res, "cursor", None) self.cursor = getattr(res, "cursor", None)
@@ -228,13 +314,21 @@ class ATProtoSocialFollowingTimelinePanel(ATProtoSocialHomeTimelinePanel):
author = getattr(post, "author", None) author = getattr(post, "author", None)
text = getattr(record, "text", "") if record else "" text = getattr(record, "text", "") if record else ""
handle = getattr(author, "handle", "") if author 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) indexed_at = getattr(post, "indexed_at", None)
self.items.append({ item = {
"uri": getattr(post, "uri", ""), "uri": getattr(post, "uri", ""),
"author": handle, "author": display_name or handle,
"handle": handle,
"display_name": display_name,
"text": text, "text": text,
"indexed_at": indexed_at, "indexed_at": indexed_at,
}) }
self._append_item(item, to_top=self._reverse())
self._render_list(replace=True) self._render_list(replace=True)
return len(self.items) return len(self.items)
except Exception: except Exception:
@@ -242,3 +336,46 @@ class ATProtoSocialFollowingTimelinePanel(ATProtoSocialHomeTimelinePanel):
self.buffer.list.clear() self.buffer.list.clear()
self.buffer.list.insert_item(False, _("Error"), _("Could not load timeline."), "") self.buffer.list.insert_item(False, _("Error"), _("Could not load timeline."), "")
return 0 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

View File

@@ -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(),
}