""" Compose post dialog for creating new posts """ from PySide6.QtWidgets import ( QDialog, QVBoxLayout, QHBoxLayout, QTextEdit, QPushButton, QLabel, QDialogButtonBox, QCheckBox, QComboBox, QGroupBox, QLineEdit, QSpinBox, QMessageBox ) from PySide6.QtCore import Qt, Signal, QThread from PySide6.QtGui import QKeySequence, QShortcut import logging from accessibility.accessible_combo import AccessibleComboBox from audio.sound_manager import SoundManager from config.settings import SettingsManager from activitypub.client import ActivityPubClient from widgets.autocomplete_textedit import AutocompleteTextEdit from widgets.media_upload_widget import MediaUploadWidget # NOTE: PostThread removed - now using centralized PostManager in main_window.py # This eliminates duplicate posting logic and centralizes all post operations class ComposeDialog(QDialog): """Dialog for composing new posts""" post_sent = Signal(dict) # Emitted when a post is ready to send def __init__(self, account_manager, parent=None): super().__init__(parent) self.settings = SettingsManager() self.sound_manager = SoundManager(self.settings) self.account_manager = account_manager self.media_upload_widget = None self.logger = logging.getLogger('bifrost.compose') self.setup_ui() self.setup_shortcuts() self.load_default_settings() def setup_ui(self): """Initialize the compose dialog UI""" self.setWindowTitle("Compose Post") self.setMinimumSize(500, 300) self.setModal(True) layout = QVBoxLayout(self) # Character count label self.char_count_label = QLabel("Characters: 0/500") self.char_count_label.setAccessibleName("Character Count") layout.addWidget(self.char_count_label) # Main text area with autocomplete self.text_edit = AutocompleteTextEdit(sound_manager=self.sound_manager) self.text_edit.setAccessibleName("Post Content") self.text_edit.setAccessibleDescription("Enter your post content here. Type @ for mentions, : for emojis. Press Tab to move to post options.") self.text_edit.setPlaceholderText("What's on your mind? Type @ for mentions, : for emojis") self.text_edit.setTabChangesFocus(True) # Allow Tab to exit the text area self.text_edit.textChanged.connect(self.update_char_count) self.text_edit.mention_requested.connect(self.load_mention_suggestions) self.text_edit.emoji_requested.connect(self.load_emoji_suggestions) layout.addWidget(self.text_edit) # Options group options_group = QGroupBox("Post Options") options_layout = QVBoxLayout(options_group) # Post settings row 1: Visibility and Content Type settings_row1 = QHBoxLayout() # Visibility settings settings_row1.addWidget(QLabel("Visibility:")) self.visibility_combo = AccessibleComboBox() self.visibility_combo.setAccessibleName("Post Visibility") self.visibility_combo.addItems([ "Public", "Unlisted", "Followers Only", "Direct Message" ]) settings_row1.addWidget(self.visibility_combo) settings_row1.addWidget(QLabel("Content Type:")) self.content_type_combo = AccessibleComboBox() self.content_type_combo.setAccessibleName("Content Type") self.content_type_combo.setAccessibleDescription("Choose the format for this post") self.content_type_combo.addItem("Plain Text", "text/plain") self.content_type_combo.addItem("Markdown", "text/markdown") self.content_type_combo.addItem("HTML", "text/html") settings_row1.addWidget(self.content_type_combo) settings_row1.addStretch() options_layout.addLayout(settings_row1) # Content warnings self.cw_checkbox = QCheckBox("Add Content Warning") self.cw_checkbox.setAccessibleName("Content Warning Toggle") self.cw_checkbox.toggled.connect(self.toggle_content_warning) options_layout.addWidget(self.cw_checkbox) self.cw_edit = QTextEdit() self.cw_edit.setAccessibleName("Content Warning Text") self.cw_edit.setAccessibleDescription("Enter content warning description. Press Tab to move to next field.") self.cw_edit.setPlaceholderText("Describe what this post contains...") self.cw_edit.setMaximumHeight(60) self.cw_edit.setTabChangesFocus(True) # Allow Tab to exit the content warning field self.cw_edit.hide() options_layout.addWidget(self.cw_edit) # Poll options self.poll_checkbox = QCheckBox("Add Poll") self.poll_checkbox.setAccessibleName("Poll Toggle") self.poll_checkbox.toggled.connect(self.toggle_poll) options_layout.addWidget(self.poll_checkbox) # Poll container (hidden by default) self.poll_container = QGroupBox("Poll Options") self.poll_container.hide() poll_layout = QVBoxLayout(self.poll_container) # Poll options self.poll_options = [] for i in range(4): # Support up to 4 poll options option_layout = QHBoxLayout() option_layout.addWidget(QLabel(f"Option {i+1}:")) option_edit = QLineEdit() option_edit.setAccessibleName(f"Poll Option {i+1}") option_edit.setAccessibleDescription(f"Enter poll option {i+1}. Leave empty if not needed.") option_edit.setPlaceholderText(f"Poll option {i+1}...") if i >= 2: # First two options are required, others optional option_edit.setPlaceholderText(f"Poll option {i+1} (optional)...") option_layout.addWidget(option_edit) self.poll_options.append(option_edit) poll_layout.addLayout(option_layout) # Poll settings poll_settings_layout = QHBoxLayout() # Duration poll_settings_layout.addWidget(QLabel("Duration:")) self.poll_duration = QSpinBox() self.poll_duration.setAccessibleName("Poll Duration in Hours") self.poll_duration.setAccessibleDescription("How long should the poll run? In hours.") self.poll_duration.setMinimum(1) self.poll_duration.setMaximum(24 * 7) # 1 week max self.poll_duration.setValue(24) # Default 24 hours self.poll_duration.setSuffix(" hours") poll_settings_layout.addWidget(self.poll_duration) poll_settings_layout.addStretch() # Multiple choice option self.poll_multiple = QCheckBox("Allow multiple choices") self.poll_multiple.setAccessibleName("Multiple Choice Toggle") poll_settings_layout.addWidget(self.poll_multiple) poll_layout.addLayout(poll_settings_layout) options_layout.addWidget(self.poll_container) layout.addWidget(options_group) # Media upload section - create carefully to avoid crashes try: client = self.account_manager.get_client_for_active_account() if client: self.media_upload_widget = MediaUploadWidget(client, self.sound_manager) self.media_upload_widget.media_changed.connect(self.update_char_count) layout.addWidget(self.media_upload_widget) else: # Create placeholder when no account is available self.media_upload_widget = None media_placeholder = QLabel("Please log in to upload media") media_placeholder.setAccessibleName("Media Upload Status") media_placeholder.setStyleSheet("color: #666; font-style: italic; padding: 10px;") layout.addWidget(media_placeholder) except Exception as e: self.logger.error(f"Failed to create media upload widget: {e}") self.media_upload_widget = None # Add error placeholder error_placeholder = QLabel("Media upload temporarily unavailable") error_placeholder.setAccessibleName("Media Upload Error") error_placeholder.setStyleSheet("color: #888; font-style: italic; padding: 10px;") layout.addWidget(error_placeholder) # Button box button_box = QDialogButtonBox() # Post button self.post_button = QPushButton("&Post") self.post_button.setAccessibleName("Send Post") self.post_button.setDefault(True) self.post_button.clicked.connect(self.send_post) button_box.addButton(self.post_button, QDialogButtonBox.AcceptRole) # Cancel button cancel_button = QPushButton("&Cancel") cancel_button.setAccessibleName("Cancel Post") cancel_button.clicked.connect(self.reject) button_box.addButton(cancel_button, QDialogButtonBox.RejectRole) layout.addWidget(button_box) # Set initial focus self.text_edit.setFocus() def setup_shortcuts(self): """Set up keyboard shortcuts""" # Ctrl+Enter to send post send_shortcut = QShortcut(QKeySequence("Ctrl+Return"), self) send_shortcut.activated.connect(self.send_post) # Escape to cancel cancel_shortcut = QShortcut(QKeySequence.Cancel, self) cancel_shortcut.activated.connect(self.reject) def load_default_settings(self): """Load default settings from configuration""" # Set default content type default_type = self.settings.get('composition', 'default_content_type', 'text/plain') index = self.content_type_combo.findData(default_type) if index >= 0: self.content_type_combo.setCurrentIndex(index) def toggle_content_warning(self, enabled: bool): """Toggle content warning field visibility""" if enabled: self.cw_edit.show() # Don't automatically focus - let user tab to it naturally else: self.cw_edit.hide() self.cw_edit.clear() def toggle_poll(self, enabled: bool): """Toggle poll options visibility""" if enabled: self.poll_container.show() # Focus on first poll option for easy access self.poll_options[0].setFocus() else: self.poll_container.hide() # Clear all poll options for option_edit in self.poll_options: option_edit.clear() def update_char_count(self): """Update character count display""" text = self.text_edit.toPlainText() char_count = len(text) self.char_count_label.setText(f"Characters: {char_count}/500") # Enable/disable post button based on content has_content = bool(text.strip()) within_limit = char_count <= 500 self.post_button.setEnabled(has_content and within_limit) # Update accessibility if char_count > 500: self.char_count_label.setAccessibleDescription("Character limit exceeded") else: self.char_count_label.setAccessibleDescription(f"{500 - char_count} characters remaining") def send_post(self): """Send the post""" content = self.text_edit.toPlainText().strip() if not content: return # Get active account active_account = self.account_manager.get_active_account() if not active_account: QMessageBox.warning(self, "No Account", "Please add an account before posting.") return # Get post settings visibility_text = self.visibility_combo.currentText() visibility_map = { "Public": "public", "Unlisted": "unlisted", "Followers Only": "private", "Direct Message": "direct" } visibility = visibility_map.get(visibility_text, "public") # Get content type content_type = self.content_type_combo.currentData() content_warning = None if self.cw_checkbox.isChecked(): content_warning = self.cw_edit.toPlainText().strip() # Handle poll data poll_data = None if self.poll_checkbox.isChecked(): poll_options = [] for option_edit in self.poll_options: option_text = option_edit.text().strip() if option_text: poll_options.append(option_text) # Validate poll (need at least 2 options) if len(poll_options) < 2: QMessageBox.warning(self, "Invalid Poll", "Polls need at least 2 options.") return # Create poll data poll_data = { 'options': poll_options, 'expires_in': self.poll_duration.value() * 3600, # Convert hours to seconds 'multiple': self.poll_multiple.isChecked() } # Check if we need to upload media first media_ids = [] if self.media_upload_widget and self.media_upload_widget.has_media(): if not self.media_upload_widget.all_uploaded(): # Upload media first self.media_upload_widget.upload_all_media() # TODO: We should wait for uploads to complete before posting # For now, we'll post immediately and let the API handle it media_ids = self.media_upload_widget.get_media_ids() # Start background posting post_data = { 'account': active_account, 'content': content, 'visibility': visibility, 'content_type': content_type, 'content_warning': content_warning, 'poll': poll_data, 'media_ids': media_ids } # NOTE: Sound handling moved to centralized PostManager to avoid duplication # Emit signal with all post data for background processing by PostManager self.post_sent.emit(post_data) # Close dialog immediately self.accept() def load_mention_suggestions(self, prefix: str): """Load mention suggestions based on prefix""" try: # Get the active account and create API client client = self.account_manager.get_client_for_active_account() if not client: return # Get current user's account ID current_user = client.verify_credentials() current_account_id = current_user['id'] # Collect usernames from multiple sources usernames = set() # 1. Search for accounts matching the prefix if len(prefix) >= 1: # Search when user has typed at least 1 character try: search_results = client.search_accounts(prefix, limit=40) for account in search_results: # Use full fediverse handle (acct field) or construct it full_handle = account.get('acct', '') if not full_handle: username = account.get('username', '') domain = account.get('url', '').split('/')[2] if account.get('url') else '' if username and domain: full_handle = f"{username}@{domain}" else: full_handle = username if full_handle: usernames.add(full_handle) except Exception as e: self.logger.error(f"Account search failed: {e}") # 2. Get followers (people who follow you) try: followers = client.get_followers(current_account_id, limit=100) for follower in followers: # Use full fediverse handle full_handle = follower.get('acct', '') if not full_handle: username = follower.get('username', '') domain = follower.get('url', '').split('/')[2] if follower.get('url') else '' if username and domain: full_handle = f"{username}@{domain}" else: full_handle = username if full_handle and full_handle.lower().startswith(prefix.lower()): usernames.add(full_handle) except Exception as e: self.logger.error(f"Failed to get followers: {e}") # 3. Get following (people you follow) try: following = client.get_following(current_account_id, limit=100) for account in following: # Use full fediverse handle full_handle = account.get('acct', '') if not full_handle: username = account.get('username', '') domain = account.get('url', '').split('/')[2] if account.get('url') else '' if username and domain: full_handle = f"{username}@{domain}" else: full_handle = username if full_handle and full_handle.lower().startswith(prefix.lower()): usernames.add(full_handle) except Exception as e: self.logger.error(f"Failed to get following: {e}") # Convert to sorted list filtered = sorted(list(usernames)) # Only use real API data - no fallback to incomplete sample data # The empty list will trigger a fresh API call if needed self.text_edit.update_mention_list(filtered) except Exception as e: self.logger.error(f"Failed to load mention suggestions: {e}") # Fallback to empty list self.text_edit.update_mention_list([]) def load_emoji_suggestions(self, prefix: str): """Load emoji suggestions based on prefix""" # The AutocompleteTextEdit already has a built-in emoji list # This method is called when the signal is emitted, but the # autocomplete logic is handled internally by the text edit widget # We don't need to do anything here since emojis are pre-loaded pass def get_post_data(self) -> dict: """Get the composed post data""" data = { 'content': self.text_edit.toPlainText().strip(), 'visibility': self.visibility_combo.currentText().lower().replace(" ", "_"), 'content_warning': self.cw_edit.toPlainText().strip() if self.cw_checkbox.isChecked() else None } # Add media IDs if available if self.media_upload_widget and self.media_upload_widget.has_media(): data['media_ids'] = self.media_upload_widget.get_media_ids() return data