Files
bifrost/src/widgets/compose_dialog.py
Storm Dragon 8b9187e23f Implement comprehensive professional logging system
Added complete logging infrastructure for AI-only development debugging:

**Core Logging Features:**
- Command-line debug flags: -d (console) and -d filename (file output)
- Custom log format: "message - severity - timestamp"
- Professional Python logging with hierarchical loggers (bifrost.module)
- Clean separation: debug mode vs production (warnings/errors only)

**Comprehensive Coverage - Replaced 55+ Print Statements:**
- timeline_view.py: Timeline operations, new content detection, sound events
- main_window.py: Auto-refresh system, streaming mode, UI events
- activitypub/client.py: API calls, streaming connections, server detection
- audio/sound_manager.py: Sound playback, pack loading, volume control
- error_manager.py: Centralized error handling with proper log levels
- All remaining modules: Complete print statement elimination

**Enhanced Auto-Refresh Debugging:**
- Fixed repetitive refresh interval logging (only logs on changes)
- Added detailed auto-refresh execution tracing with timing
- New content detection logging with post ID tracking
- Sound event logging showing which sounds play and why

**Sound System Visibility:**
- Complete audio event logging with file paths and volumes
- Sound pack loading and fallback detection
- Audio playback success/failure with detailed error context

**Documentation Updates:**
- README.md: Complete debug system documentation for users
- CLAUDE.md: Professional logging guidelines for AI development
- Comprehensive usage examples and troubleshooting guides

This logging system provides essential debugging capabilities for
the AI-only development constraint, enabling systematic issue
resolution without human code intervention.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-22 23:32:55 -04:00

449 lines
19 KiB
Python

"""
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