# -*- coding: utf-8 -*- import wx import logging from pubsub import pub from multiplatform_widgets import widgets # Assuming this provides generic widgets from approve.translation import translate as _ # For Approve's _ shortcut from approve.notifications import NotificationError logger = logging.getLogger(__name__) # Supported languages for posts (ISO 639-1 codes) - can be expanded # This might ideally come from the session or a global config SUPPORTED_LANG_CHOICES = { _("English"): "en", _("Spanish"): "es", _("French"): "fr", _("German"): "de", _("Japanese"): "ja", _("Portuguese"): "pt", _("Russian"): "ru", _("Chinese"): "zh", # Add more as needed } class ComposeDialog(wx.Dialog): def __init__(self, parent, session, reply_to_uri: str | None = None, quote_uri: str | None = None, initial_text: str = ""): super(ComposeDialog, self).__init__(parent, title=_("Compose Post"), style=wx.DEFAULT_DIALOG_STYLE | wx.RESIZE_BORDER) self.session = session self.panel_config = self.session.compose_panel.get_panel_configuration() self.reply_to_uri = reply_to_uri self.initial_quote_uri = quote_uri # Store initial quote URI self.current_quote_uri = quote_uri # Mutable quote URI self.attached_files_info = [] # List of dicts: {"path": str, "alt_text": str} self._init_ui(initial_text) self.SetMinSize((550, 450)) # Increased min size self.CentreOnParent() def _init_ui(self, initial_text: str): panel = wx.Panel(self) main_sizer = wx.BoxSizer(wx.VERTICAL) # Reply Info (if applicable) if self.reply_to_uri: # In a real app, fetch & show post snippet or author reply_info_label = wx.StaticText(panel, label=_("Replying to: {uri_placeholder}").format(uri_placeholder=self.reply_to_uri[-10:])) reply_info_label.SetToolTip(self.reply_to_uri) main_sizer.Add(reply_info_label, 0, wx.EXPAND | wx.LEFT | wx.RIGHT | wx.TOP, 5) # Text Area self.text_ctrl = wx.TextCtrl(panel, style=wx.TE_MULTILINE | wx.TE_RICH2 | wx.HSCROLL) self.text_ctrl.SetValue(initial_text) self.text_ctrl.Bind(wx.EVT_TEXT, self.on_text_changed) main_sizer.Add(self.text_ctrl, 1, wx.EXPAND | wx.ALL, 5) # Character Counter self.max_chars = self.panel_config.get("max_chars", 0) self.char_count_label = wx.StaticText(panel, label=f"0 / {self.max_chars if self.max_chars > 0 else 'N/A'}") main_sizer.Add(self.char_count_label, 0, wx.ALIGN_RIGHT | wx.RIGHT | wx.BOTTOM, 5) self.on_text_changed(None) # Attachments Area self.max_media_attachments = self.panel_config.get("max_media_attachments", 0) if self.max_media_attachments > 0: attachment_sizer = wx.StaticBoxSizer(wx.VERTICAL, panel, _("Media Attachments") + f" (Max: {self.max_media_attachments})") self.attachment_list = wx.ListBox(attachment_sizer.GetStaticBox(), style=wx.LB_SINGLE, size=(-1, 60)) # Fixed height for listbox attachment_sizer.Add(self.attachment_list, 1, wx.EXPAND | wx.ALL, 5) attach_btn_sizer = wx.BoxSizer(wx.HORIZONTAL) self.add_attachment_btn = wx.Button(attachment_sizer.GetStaticBox(), label=_("Add Media...")) self.add_attachment_btn.Bind(wx.EVT_BUTTON, self.on_add_attachment) attach_btn_sizer.Add(self.add_attachment_btn, 0, wx.ALL, 2) self.remove_attachment_btn = wx.Button(attachment_sizer.GetStaticBox(), label=_("Remove Selected")) self.remove_attachment_btn.Bind(wx.EVT_BUTTON, self.on_remove_attachment) self.remove_attachment_btn.Enable(False) self.attachment_list.Bind(wx.EVT_LISTBOX, lambda evt: self.remove_attachment_btn.Enable(self.attachment_list.GetSelection() != wx.NOT_FOUND)) attach_btn_sizer.Add(self.remove_attachment_btn, 0, wx.ALL, 2) attachment_sizer.Add(attach_btn_sizer, 0, wx.ALIGN_LEFT) main_sizer.Add(attachment_sizer, 0, wx.EXPAND | wx.ALL, 5) # Quoting Area if self.panel_config.get("supports_quoting", False): quote_box_sizer = wx.StaticBoxSizer(wx.VERTICAL, panel, _("Quoting Post")) quote_display_sizer = wx.BoxSizer(wx.HORIZONTAL) self.quote_uri_text_display = wx.TextCtrl(quote_box_sizer.GetStaticBox(), value=self.current_quote_uri or _("None"), style=wx.TE_READONLY | wx.BORDER_NONE) self.quote_uri_text_display.SetBackgroundColour(panel.GetBackgroundColour()) quote_display_sizer.Add(wx.StaticText(quote_box_sizer.GetStaticBox(), label=_("Quoting URI: ")), 0, wx.ALIGN_CENTER_VERTICAL | wx.ALL, 2) quote_display_sizer.Add(self.quote_uri_text_display, 1, wx.ALIGN_CENTER_VERTICAL | wx.ALL, 2) quote_box_sizer.Add(quote_display_sizer, 0, wx.EXPAND | wx.LEFT | wx.RIGHT, 2) quote_btn_sizer = wx.BoxSizer(wx.HORIZONTAL) self.add_quote_btn = wx.Button(quote_box_sizer.GetStaticBox(), label=_("Set/Change Quote...")) self.add_quote_btn.Bind(wx.EVT_BUTTON, self.on_add_quote) quote_btn_sizer.Add(self.add_quote_btn, 0, wx.ALL, 2) self.remove_quote_btn = wx.Button(quote_box_sizer.GetStaticBox(), label=_("Remove Quote")) self.remove_quote_btn.Bind(wx.EVT_BUTTON, self.on_remove_quote) self.remove_quote_btn.Enable(bool(self.current_quote_uri)) quote_btn_sizer.Add(self.remove_quote_btn, 0, wx.ALL, 2) quote_box_sizer.Add(quote_btn_sizer, 0, wx.ALIGN_LEFT) main_sizer.Add(quote_box_sizer, 0, wx.EXPAND | wx.ALL, 5) # Options (Content Warning, Language) options_box = wx.StaticBoxSizer(wx.VERTICAL, panel, _("Options")) options_grid_sizer = wx.FlexGridSizer(cols=2, vgap=5, hgap=5) options_grid_sizer.AddGrowableCol(1, 1) if self.panel_config.get("supports_content_warning", False): self.sensitive_checkbox = wx.CheckBox(options_box.GetStaticBox(), label=_("Sensitive content (CW)")) self.sensitive_checkbox.Bind(wx.EVT_CHECKBOX, self.on_sensitive_changed) options_grid_sizer.Add(self.sensitive_checkbox, 0, wx.ALIGN_CENTER_VERTICAL | wx.ALL, 2) self.spoiler_text_ctrl = wx.TextCtrl(options_box.GetStaticBox()) self.spoiler_text_ctrl.SetHint(_("Content warning text (optional)")) self.spoiler_text_ctrl.Enable(False) options_grid_sizer.Add(self.spoiler_text_ctrl, 1, wx.EXPAND | wx.ALIGN_CENTER_VERTICAL | wx.ALL, 2) if self.panel_config.get("supports_language_selection", False): lang_label = wx.StaticText(options_box.GetStaticBox(), label=_("Languages:")) options_grid_sizer.Add(lang_label, 0, wx.ALIGN_CENTER_VERTICAL | wx.ALL, 2) self.max_langs = self.panel_config.get("max_languages", 1) self.lang_choices_map = SUPPORTED_LANG_CHOICES # Using global for now lang_display_names = list(self.lang_choices_map.keys()) if self.max_langs == 1: # Single choice choices = [_("Automatic")] + lang_display_names self.lang_choice_ctrl = wx.Choice(options_box.GetStaticBox(), choices=choices) self.lang_choice_ctrl.SetSelection(0) # Default to Automatic/None else: # Multiple choices self.lang_choice_ctrl = wx.CheckListBox(options_box.GetStaticBox(), choices=lang_display_names, size=(-1, 70)) self.lang_choice_ctrl.Bind(wx.EVT_CHECKLISTBOX, self.on_lang_checklist_changed) options_grid_sizer.Add(self.lang_choice_ctrl, 1, wx.EXPAND | wx.ALIGN_CENTER_VERTICAL | wx.ALL, 2) if options_grid_sizer.GetChildren(): options_box.Add(options_grid_sizer, 1, wx.EXPAND | wx.ALL, 0) # No border for grid sizer itself main_sizer.Add(options_box, 0, wx.EXPAND | wx.ALL, 5) # Buttons (Send, Cancel) btn_sizer = wx.StdDialogButtonSizer() self.send_btn = wx.Button(panel, wx.ID_OK, _("Send")) self.send_btn.SetDefault() self.send_btn.Bind(wx.EVT_BUTTON, self.on_send) btn_sizer.AddButton(self.send_btn) cancel_btn = wx.Button(panel, wx.ID_CANCEL, _("Cancel")) btn_sizer.AddButton(cancel_btn) btn_sizer.Realize() main_sizer.Add(btn_sizer, 0, wx.ALIGN_CENTER | wx.ALL, 5) panel.SetSizer(main_sizer) self.Fit() def on_text_changed(self, event): text_length = len(self.text_ctrl.GetValue()) self.char_count_label.SetLabel(f"{text_length} / {self.max_chars}") if self.max_chars > 0 and text_length > self.max_chars: self.char_count_label.SetForegroundColour(wx.RED) else: self.char_count_label.SetForegroundColour(wx.BLACK) # System default def on_add_attachment(self, event): max_attachments = self.panel_config.get("max_media_attachments", 0) if len(self.attached_files_info) >= self.max_media_attachments: wx.MessageBox(_("Maximum number of attachments ({max}) reached.").format(max=self.max_media_attachments), _("Attachment Limit"), wx.OK | wx.ICON_INFORMATION) return supported_mimes = self.panel_config.get("supported_media_types", []) wildcard_parts = [] if not supported_mimes: # Default if none specified by session wildcard_parts.append("All files (*.*)|*.*") else: for mime_type in supported_mimes: # Example: "image/jpeg" -> "JPEG files (*.jpg;*.jpeg)|*.jpg;*.jpeg" name = mime_type.split('/')[0].capitalize() + " " + mime_type.split('/')[1].upper() if mime_type == "image/jpeg": exts = "*.jpg;*.jpeg" elif mime_type == "image/png": exts = "*.png" elif mime_type == "image/gif": exts = "*.gif" # If supported else: exts = "*." + mime_type.split('/')[-1] wildcard_parts.append(f"{name} ({exts})|{exts}") wildcard = "|".join(wildcard_parts) if wildcard_parts else wx.FileSelectorDefaultWildcardStr dialog = wx.FileDialog(self, _("Select Media File"), wildcard=wildcard, style=wx.FD_OPEN | wx.FD_FILE_MUST_EXIST) if dialog.ShowModal() == wx.ID_OK: path = dialog.GetPath() alt_text = "" if self.panel_config.get("supports_alternative_text", False) and \ any(pt in path.lower() for pt in ['.jpg', '.jpeg', '.png']): # crude check for image alt_text_dialog = wx.TextEntryDialog(self, _("Enter accessibility description (alt text) for the image:"), _("Image Description")) if alt_text_dialog.ShowModal() == wx.ID_OK: alt_text = alt_text_dialog.GetValue() alt_text_dialog.Destroy() self.attached_files_info.append({"path": path, "alt_text": alt_text}) self.attachment_list.Append(os.path.basename(path) + (f" ({_('Alt:')} {alt_text})" if alt_text else "")) dialog.Destroy() def on_remove_attachment(self, event): selected_index = self.attachment_list.GetSelection() if selected_index != wx.NOT_FOUND: self.attachment_list.Delete(selected_index) del self.attached_files_info[selected_index] def on_add_quote(self, event): dialog = wx.TextEntryDialog(self, _("Enter the AT-URI of the Bluesky post to quote:"), _("Quote Post"), self.current_quote_uri or "") if dialog.ShowModal() == wx.ID_OK: self.current_quote_uri = dialog.GetValue().strip() self.quote_uri_text_display.SetValue(self.current_quote_uri or _("None")) self.remove_quote_btn.Enable(bool(self.current_quote_uri)) dialog.Destroy() def on_remove_quote(self, event): self.current_quote_uri = None self.quote_uri_text_display.SetValue(_("None")) self.remove_quote_btn.Enable(False) def on_sensitive_changed(self, event): if hasattr(self, 'spoiler_text_ctrl'): self.spoiler_text_ctrl.Enable(event.IsChecked()) if event.IsChecked(): self.spoiler_text_ctrl.SetFocus() def on_lang_checklist_changed(self, event): """Ensure no more than max_languages are selected for CheckListBox.""" if isinstance(self.lang_choice_ctrl, wx.CheckListBox): checked_indices = self.lang_choice_ctrl.GetCheckedItems() if len(checked_indices) > self.max_langs: # Find the item that was just checked to cause the overflow # This is a bit tricky as EVT_CHECKLISTBOX triggers after the change. # A simpler approach is to inform the user and let them uncheck. wx.MessageBox( _("You can select a maximum of {num} languages.").format(num=self.max_langs), _("Language Selection Limit"), wx.OK | wx.ICON_EXCLAMATION ) # Optionally, uncheck the last checked item if possible to determine # For now, just warn. User has to manually correct. def on_send(self, event): # Renamed from async on_send text_content = self.text_ctrl.GetValue() if not text_content.strip() and not self.attached_files_info and not self.current_quote_uri: wx.MessageBox(_("Cannot send an empty post."), _("Error"), wx.OK | wx.ICON_ERROR) return # Language processing langs = [] if hasattr(self, 'lang_choice_ctrl'): if isinstance(self.lang_choice_ctrl, wx.Choice): sel_idx = self.lang_choice_ctrl.GetSelection() if sel_idx > 0: # Index 0 is empty/no selection lang_display_name = self.lang_choice_ctrl.GetString(sel_idx) langs.append(self.lang_choices_map[lang_display_name]) elif isinstance(self.lang_choice_ctrl, wx.CheckListBox): checked_indices = self.lang_choice_ctrl.GetCheckedItems() if len(checked_indices) > self.max_langs: wx.MessageBox(_("Please select no more than {num} languages.").format(num=self.max_langs), _("Language Error"), wx.OK | wx.ICON_ERROR) return for idx in checked_indices: lang_display_name = self.lang_choice_ctrl.GetString(idx) langs.append(self.lang_choices_map[lang_display_name]) # Files and Alt Texts files_to_send = [f_info["path"] for f_info in self.attached_files_info] alt_texts_to_send = [f_info["alt_text"] for f_info in self.attached_files_info] # Content Warning cw_text = None is_sensitive_flag = False if hasattr(self, 'sensitive_checkbox') and self.sensitive_checkbox.IsChecked(): is_sensitive_flag = True if hasattr(self, 'spoiler_text_ctrl'): cw_text = self.spoiler_text_ctrl.GetValue().strip() or None # Use None if empty for Bluesky kwargs_for_send = { "quote_uri": self.current_quote_uri, "langs": langs if langs else None, "media_alt_texts": alt_texts_to_send if alt_texts_to_send else None, # "tags" could be extracted from text server-side or client-side (not implemented here) } # Filter out None values from kwargs to avoid sending them if not set kwargs_for_send = {k: v for k, v in kwargs_for_send.items() if v is not None} try: self.send_btn.Disable() # This is an async call, so it should be handled appropriately in wxPython # For simplicity in this step, assuming it's handled by the caller or a wrapper # In a real wxPython app, this would involve asyncio.create_task and wx.CallAfter # or running the send in a separate thread and using wx.CallAfter for UI updates. # For now, we'll make this method async and let the caller handle it. # wx.BeginBusyCursor() # Indicate work # Using pubsub to decouple UI from direct async call to session pub.sendMessage( "compose_dialog.send_post", session=self.session, text=text_content, files=files_to_send if files_to_send else None, reply_to=self.reply_to_uri, cw_text=cw_text, is_sensitive=is_sensitive_flag, kwargs=kwargs_for_send ) # Success will be signaled by another pubsub message if needed, or just close. # self.EndModal(wx.ID_OK) # Moved to controller after successful send via pubsub except NotificationError as e: wx.MessageBox(str(e), _("Post Error"), wx.OK | wx.ICON_ERROR) except Exception as e: logger.error("Error sending post from compose dialog: %s", e, exc_info=True) wx.MessageBox(_("An unexpected error occurred: {error}").format(error=str(e)), _("Error"), wx.OK | wx.ICON_ERROR) finally: # wx.EndBusyCursor() if not self.IsBeingDeleted(): # Ensure dialog still exists self.send_btn.Enable() # Do not automatically close here; let the controller do it on success signal. # self.EndModal(wx.ID_OK) # if successful and no further UI feedback needed in dialog def get_data(self): """Helper to get all data, though on_send handles it directly.""" # This method isn't strictly necessary if on_send does all the work, # but can be useful for other patterns. pass if __name__ == '__main__': # Example usage (requires a mock session and panel_config) app = wx.App(False) class MockComposePanel: def get_panel_configuration(self): return { "max_chars": 300, "max_media_attachments": 4, "supported_media_types": ["image/jpeg", "image/png"], "supports_alternative_text": True, "supports_content_warning": True, "supports_language_selection": True, "max_languages": 3, "supports_quoting": True, } class MockSession: def __init__(self): self.compose_panel = MockComposePanel() self.uid = "mock_user" # Needed by some base methods if called async def send_message(self, message, files=None, reply_to=None, cw_text=None, is_sensitive=False, **kwargs): print("MockSession.send_message called:") print(f" Text: {message}") print(f" Files: {files}") print(f" Reply To: {reply_to}") print(f" CW: {cw_text}, Sensitive: {is_sensitive}") print(f" kwargs: {kwargs}") # Simulate success or failure # raise NotificationError("This is a mock send error!") return "at://did:plc:mockposturi/app.bsky.feed.post/mockrkey" # Pubsub listener for the send_post event (simulates what mainController would do) def on_actual_send(session, text, files, reply_to, cw_text, is_sensitive, kwargs): print("Pubsub: compose_dialog.send_post received. Calling session.send_message...") async def do_send(): try: uri = await session.send_message( message=text, files=files, reply_to=reply_to, cw_text=cw_text, is_sensitive=is_sensitive, **kwargs ) print(f"Pubsub: Send successful, URI: {uri}") # In real app, would call dialog.EndModal(wx.ID_OK) via wx.CallAfter wx.CallAfter(dialog.EndModal, wx.ID_OK) except Exception as e: print(f"Pubsub: Send failed: {e}") # In real app, show error and re-enable send button in dialog via wx.CallAfter wx.CallAfter(wx.MessageBox, str(e), "Error", wx.OK | wx.ICON_ERROR, dialog) wx.CallAfter(dialog.send_btn.Enable, True) asyncio.create_task(do_send()) pub.subscribe(on_actual_send, "compose_dialog.send_post") session = MockSession() # Example: dialog = ComposeDialog(None, session, reply_to_uri="at://reply_uri", quote_uri="at://quote_uri", initial_text="Hello") dialog = ComposeDialog(None, session, initial_text="Hello Bluesky!") dialog.ShowModal() dialog.Destroy() app.MainLoop()