- 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>
255 lines
10 KiB
Python
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
|
|
} |