diff --git a/src/controller/blueski/messages.py b/src/controller/blueski/messages.py index ddb15184..1bf06f4a 100644 --- a/src/controller/blueski/messages.py +++ b/src/controller/blueski/messages.py @@ -3,6 +3,13 @@ from __future__ import annotations import logging from typing import Any +import arrow +import languageHandler +import output +import widgetUtils +from controller import messages as base_messages +from wxUI.dialogs.blueski import postDialogs + # Translation function is provided globally by TWBlue's language handler (_) logger = logging.getLogger(__name__) @@ -89,3 +96,167 @@ def format_error_message(error_description: str, details: str | None = None) -> # } logger.info("Blueski messages module loaded (placeholders).") + + +def _g(obj: Any, key: str, default: Any = None) -> Any: + if isinstance(obj, dict): + return obj.get(key, default) + return getattr(obj, key, default) + + +def has_post_data(item: Any) -> bool: + post = _g(item, "post") + record = _g(post, "record") if post is not None else None + if record is None: + record = _g(item, "record") + return record is not None or post is not None + + +def _extract_labels(obj: Any) -> list[dict[str, Any]]: + labels = _g(obj, "labels", None) + if labels is None: + return [] + if isinstance(labels, dict): + labels = labels.get("values", []) + if isinstance(labels, list): + return labels + return [] + + +def _extract_cw_text(post: Any, record: Any) -> str: + labels = _extract_labels(post) + _extract_labels(record) + for label in labels: + val = _g(label, "val", "") + if val == "warn": + return _("Sensitive Content") + if isinstance(val, str) and val.startswith("warn:"): + return val.split("warn:", 1)[-1].strip() + return "" + + +def _extract_image_descriptions(post: Any, record: Any) -> str: + def _collect_images(embed: Any) -> list[Any]: + if not embed: + return [] + etype = _g(embed, "$type") or _g(embed, "py_type") or "" + if "recordWithMedia" in etype: + media = _g(embed, "media") + mtype = _g(media, "$type") or _g(media, "py_type") or "" + if "images" in mtype: + return list(_g(media, "images", []) or []) + return [] + if "images" in etype: + return list(_g(embed, "images", []) or []) + return [] + + images = [] + images.extend(_collect_images(_g(post, "embed"))) + if not images: + images.extend(_collect_images(_g(record, "embed"))) + + descriptions = [] + for idx, img in enumerate(images, start=1): + alt = _g(img, "alt", "") or "" + if alt: + descriptions.append(_("Image {index}: {alt}").format(index=idx, alt=alt)) + return "\n".join(descriptions) + + +def _format_date(raw_date: str | None, offset_hours: int = 0) -> str: + if not raw_date: + return "" + try: + ts = arrow.get(raw_date) + if offset_hours: + ts = ts.shift(hours=offset_hours) + return ts.format(_("dddd, MMMM D, YYYY H:m"), locale=languageHandler.curLang[:2]) + except Exception: + return str(raw_date)[:16].replace("T", " ") + + +def _extract_post_view_data(session: Any, item: Any) -> dict[str, Any] | None: + post = _g(item, "post", item) + record = _g(post, "record") or _g(item, "record") + if record is None: + return None + + author = _g(post, "author") or _g(item, "author") or {} + handle = _g(author, "handle", "") + display_name = _g(author, "displayName") or _g(author, "display_name") or handle or _("Unknown") + if handle and display_name != handle: + author_label = f"{display_name} (@{handle})" + elif handle: + author_label = f"@{handle}" + else: + author_label = display_name + + text = _g(record, "text", "") or "" + cw_text = _extract_cw_text(post, record) + if cw_text: + text = f"CW: {cw_text}\n\n{text}" if text else f"CW: {cw_text}" + + created_at = _g(record, "createdAt") or _g(record, "created_at") + indexed_at = _g(post, "indexedAt") or _g(post, "indexed_at") + date = _format_date(created_at or indexed_at, offset_hours=_g(session.db, "utc_offset", 0)) + + reply_count = _g(post, "replyCount", 0) or 0 + repost_count = _g(post, "repostCount", 0) or 0 + like_count = _g(post, "likeCount", 0) or 0 + + uri = _g(post, "uri") or _g(item, "uri") + item_url = "" + if uri and handle: + rkey = uri.split("/")[-1] + item_url = f"https://bsky.app/profile/{handle}/post/{rkey}" + + image_description = _extract_image_descriptions(post, record) + + return { + "author": author_label, + "text": text, + "date": date, + "replies": reply_count, + "reposts": repost_count, + "likes": like_count, + "source": _("Bluesky"), + "privacy": _("Public"), + "image_description": image_description, + "item_url": item_url, + } + + +class viewPost(base_messages.basicMessage): + def __init__(self, session: Any, item: Any): + self.session = session + data = _extract_post_view_data(session, item) + if not data: + output.speak(_("No post available to view."), True) + return + title = _("Post from {}").format(data["author"]) + self.message = postDialogs.viewPost( + text=data["text"], + reposts_count=data["reposts"], + likes_count=data["likes"], + source=data["source"], + date=data["date"], + privacy=data["privacy"], + ) + self.message.SetTitle(title) + if data["image_description"]: + self.message.image_description.Enable(True) + self.message.image_description.ChangeValue(data["image_description"]) + widgetUtils.connect_event(self.message.spellcheck, widgetUtils.BUTTON_PRESSED, self.spellcheck) + widgetUtils.connect_event(self.message.translateButton, widgetUtils.BUTTON_PRESSED, self.translate) + if data["item_url"]: + self.message.enable_button("share") + self.item_url = data["item_url"] + widgetUtils.connect_event(self.message.share, widgetUtils.BUTTON_PRESSED, self.share) + self.message.ShowModal() + + def text_processor(self): + pass + + def share(self, *args, **kwargs): + if hasattr(self, "item_url"): + output.copy(self.item_url) + output.speak(_("Link copied to clipboard.")) diff --git a/src/controller/buffers/blueski/base.py b/src/controller/buffers/blueski/base.py index 59c5b111..215deef3 100644 --- a/src/controller/buffers/blueski/base.py +++ b/src/controller/buffers/blueski/base.py @@ -7,6 +7,7 @@ import config import widgetUtils from pubsub import pub from controller.buffers.base import base +from controller.blueski import messages as blueski_messages from sessions.blueski import compose from wxUI.buffers.blueski import panels as BlueskiPanels @@ -257,35 +258,15 @@ class BaseBuffer(base.Buffer): def view_item(self, item=None): if item is None: item = self.get_item() - if not item: return - - import wx - - def g(obj, key, default=None): - if isinstance(obj, dict): - return obj.get(key, default) - return getattr(obj, key, default) - - # Handle simplified objects vs full feed items - post = g(item, "post", item) - author = g(post, "author") - record = g(post, "record") - - handle = g(author, "handle", "Unknown") - display_name = g(author, "displayName", handle) - text = g(record, "text", "") - created_at = g(record, "createdAt", "") - - # Stats - reply_count = g(post, "replyCount", 0) - repost_count = g(post, "repostCount", 0) - like_count = g(post, "likeCount", 0) - - content = f"{display_name} (@{handle})\n{created_at}\n\n{text}\n\nšŸ’¬ {reply_count} šŸ” {repost_count} ♄ {like_count}" - - dlg = wx.MessageDialog(self.buffer, content, _("View Post"), wx.OK | wx.ICON_INFORMATION) - dlg.ShowModal() - dlg.Destroy() + if not item: + return + if not blueski_messages.has_post_data(item): + pub.sendMessage("execute-action", action="user_details") + return + try: + blueski_messages.viewPost(self.session, item) + except Exception: + log.exception("Error opening Bluesky post viewer") def url_(self, *args, **kwargs): self.url() diff --git a/src/wxUI/dialogs/blueski/postDialogs.py b/src/wxUI/dialogs/blueski/postDialogs.py index 1e960e68..856b84cd 100644 --- a/src/wxUI/dialogs/blueski/postDialogs.py +++ b/src/wxUI/dialogs/blueski/postDialogs.py @@ -112,3 +112,94 @@ class Post(wx.Dialog): }) return text, files, cw_text, (lang and [lang] or []) + +class viewPost(wx.Dialog): + def set_title(self, length): + self.SetTitle(_("Post - %i characters ") % length) + + def __init__(self, text="", reposts_count=0, likes_count=0, source="", date="", privacy="", *args, **kwargs): + super(viewPost, self).__init__(parent=None, id=wx.ID_ANY, size=(850, 850)) + self.init_ui(text, reposts_count, likes_count, source, date, privacy) + + def init_ui(self, text, reposts_count, likes_count, source, date, privacy): + panel = wx.Panel(self) + main_sizer = wx.BoxSizer(wx.VERTICAL) + main_sizer.Add(self.create_text_section(panel, text), 1, wx.EXPAND | wx.ALL, 5) + main_sizer.Add(self.create_image_description_section(panel), 1, wx.EXPAND | wx.ALL, 5) + main_sizer.Add(self.create_info_section(panel, privacy, reposts_count, likes_count, source, date), 0, wx.EXPAND | wx.ALL, 5) + main_sizer.Add(self.create_buttons_section(panel), 0, wx.ALIGN_RIGHT | wx.ALL, 5) + panel.SetSizer(main_sizer) + self.SetClientSize(main_sizer.CalcMin()) + + def create_text_section(self, panel, text): + sizer = wx.StaticBoxSizer(wx.StaticBox(panel, wx.ID_ANY, _("Post")), wx.VERTICAL) + self.text = wx.TextCtrl(panel, -1, text, style=wx.TE_READONLY | wx.TE_MULTILINE) + sizer.Add(self.text, 1, wx.EXPAND | wx.ALL, 5) + return sizer + + def create_image_description_section(self, panel): + sizer = wx.StaticBoxSizer(wx.StaticBox(panel, wx.ID_ANY, _("Image description")), wx.VERTICAL) + self.image_description = wx.TextCtrl(panel, -1, style=wx.TE_READONLY | wx.TE_MULTILINE) + self.image_description.Enable(False) + sizer.Add(self.image_description, 1, wx.EXPAND | wx.ALL, 5) + return sizer + + def create_info_section(self, panel, privacy, reposts_count, likes_count, source, date): + sizer = wx.StaticBoxSizer(wx.StaticBox(panel, wx.ID_ANY, _("Information")), wx.VERTICAL) + flex_sizer = wx.FlexGridSizer(cols=3, hgap=10, vgap=10) + flex_sizer.AddGrowableCol(1) + flex_sizer.Add(wx.StaticText(panel, -1, _("Privacy")), 0, wx.ALIGN_CENTER_VERTICAL) + flex_sizer.Add(wx.TextCtrl(panel, -1, privacy, style=wx.TE_READONLY | wx.TE_MULTILINE), 1, wx.EXPAND) + flex_sizer.Add(self.create_reposts_section(panel, reposts_count), 1, wx.EXPAND | wx.ALL, 5) + flex_sizer.Add(self.create_likes_section(panel, likes_count), 1, wx.EXPAND | wx.ALL, 5) + flex_sizer.Add(wx.StaticText(panel, -1, _("Source")), 0, wx.ALIGN_CENTER_VERTICAL) + flex_sizer.Add(wx.TextCtrl(panel, -1, source, style=wx.TE_READONLY | wx.TE_MULTILINE), 1, wx.EXPAND) + flex_sizer.Add(wx.StaticText(panel, -1, _("Date")), 0, wx.ALIGN_CENTER_VERTICAL) + flex_sizer.Add(wx.TextCtrl(panel, -1, date, style=wx.TE_READONLY | wx.TE_MULTILINE), 1, wx.EXPAND) + sizer.Add(flex_sizer, 1, wx.EXPAND | wx.ALL, 5) + return sizer + + def create_reposts_section(self, panel, reposts_count): + sizer = wx.StaticBoxSizer(wx.StaticBox(panel, wx.ID_ANY, _("Reposts")), wx.VERTICAL) + self.reposts_button = wx.Button(panel, -1, str(reposts_count)) + self.reposts_button.Enable(False) + sizer.Add(self.reposts_button, 1, wx.EXPAND | wx.ALL, 5) + return sizer + + def create_likes_section(self, panel, likes_count): + sizer = wx.StaticBoxSizer(wx.StaticBox(panel, wx.ID_ANY, _("Likes")), wx.VERTICAL) + self.likes_button = wx.Button(panel, -1, str(likes_count)) + self.likes_button.Enable(False) + sizer.Add(self.likes_button, 1, wx.EXPAND | wx.ALL, 5) + return sizer + + def create_buttons_section(self, panel): + sizer = wx.BoxSizer(wx.HORIZONTAL) + self.share = wx.Button(panel, wx.ID_ANY, _("&Copy link to clipboard")) + self.share.Enable(False) + self.spellcheck = wx.Button(panel, wx.ID_ANY, _("Check &spelling...")) + self.translateButton = wx.Button(panel, wx.ID_ANY, _("&Translate...")) + cancelButton = wx.Button(panel, wx.ID_CANCEL, _("C&lose")) + cancelButton.SetDefault() + sizer.Add(self.share, 0, wx.ALL, 5) + sizer.Add(self.spellcheck, 0, wx.ALL, 5) + sizer.Add(self.translateButton, 0, wx.ALL, 5) + sizer.Add(cancelButton, 0, wx.ALL, 5) + return sizer + + def set_text(self, text): + self.text.ChangeValue(text) + + def get_text(self): + return self.text.GetValue() + + def text_focus(self): + self.text.SetFocus() + + def onSelect(self, ev): + self.text.SelectAll() + + def enable_button(self, buttonName): + if hasattr(self, buttonName): + return getattr(self, buttonName).Enable() +