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>
449 lines
19 KiB
Python
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 |