Files
bifrost/src/widgets/compose_dialog.py
Storm Dragon 460dfc52a5 Initial commit: Bifrost accessible fediverse client
- Full ActivityPub support for Pleroma, GoToSocial, and Mastodon
- Screen reader optimized interface with PySide6
- Timeline switching with tabs and keyboard shortcuts (Ctrl+1-4)
- Threaded conversation navigation with expand/collapse
- Cross-platform desktop notifications via plyer
- Customizable sound pack system with audio feedback
- Complete keyboard navigation and accessibility features
- XDG Base Directory compliant configuration
- Multiple account support with OAuth authentication

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-20 03:39:47 -04:00

255 lines
10 KiB
Python

"""
Compose post dialog for creating new posts
"""
from PySide6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QTextEdit,
QPushButton, QLabel, QDialogButtonBox, QCheckBox,
QComboBox, QGroupBox
)
from PySide6.QtCore import Qt, Signal, QThread
from PySide6.QtGui import QKeySequence, QShortcut
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
class PostThread(QThread):
"""Background thread for posting content"""
post_success = Signal(dict) # Emitted with post data on success
post_failed = Signal(str) # Emitted with error message on failure
def __init__(self, account, content, visibility, content_warning=None):
super().__init__()
self.account = account
self.content = content
self.visibility = visibility
self.content_warning = content_warning
def run(self):
"""Post the content in background"""
try:
client = ActivityPubClient(self.account.instance_url, self.account.access_token)
result = client.post_status(
content=self.content,
visibility=self.visibility,
content_warning=self.content_warning
)
self.post_success.emit(result)
except Exception as e:
self.post_failed.emit(str(e))
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.setup_ui()
self.setup_shortcuts()
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()
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)
# Visibility settings
visibility_layout = QHBoxLayout()
visibility_layout.addWidget(QLabel("Visibility:"))
self.visibility_combo = AccessibleComboBox()
self.visibility_combo.setAccessibleName("Post Visibility")
self.visibility_combo.addItems([
"Public",
"Unlisted",
"Followers Only",
"Direct Message"
])
visibility_layout.addWidget(self.visibility_combo)
visibility_layout.addStretch()
options_layout.addLayout(visibility_layout)
# 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)
layout.addWidget(options_group)
# 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 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 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")
content_warning = None
if self.cw_checkbox.isChecked():
content_warning = self.cw_edit.toPlainText().strip()
# Start background posting
post_data = {
'account': active_account,
'content': content,
'visibility': visibility,
'content_warning': content_warning
}
# Play sound when post button is pressed
self.sound_manager.play_event("post")
# Emit signal with all post data for background processing
self.post_sent.emit(post_data)
# Close dialog immediately
self.accept()
def load_mention_suggestions(self, prefix: str):
"""Load mention suggestions based on prefix"""
# TODO: Implement fetching followers/following from API
# For now, use expanded sample suggestions with realistic fediverse usernames
sample_mentions = [
"alice", "bob", "charlie", "diana", "eve", "frank", "grace", "henry", "ivy", "jack",
"admin", "moderator", "announcements", "news", "updates", "support", "help",
"alex_dev", "jane_artist", "mike_writer", "sarah_photographer", "tom_musician",
"community", "local_news", "tech_updates", "fedi_tips", "open_source",
"cat_lover", "dog_walker", "book_reader", "movie_fan", "game_dev", "web_designer",
"climate_activist", "space_enthusiast", "food_blogger", "travel_tales", "art_gallery"
]
# Filter by prefix (case insensitive)
filtered = [name for name in sample_mentions if name.lower().startswith(prefix.lower())]
self.text_edit.update_mention_list(filtered)
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"""
return {
'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
}