Fix context menu accessibility, conversation replies, and integrate polls into post details

This commit addresses several critical accessibility and functionality issues:

- Fix context menu keyboard shortcuts (Applications key and Shift+F10) in messages tab
- Resolve 404 errors when replying to private message conversations by implementing separate conversation reply handling
- Restore Enter key functionality for viewing post details
- Integrate poll voting into post details dialog as first tab instead of separate dialog
- Fix accessibility issues with poll display using QTextEdit and accessible list patterns
- Add comprehensive accessibility guidelines to CLAUDE.md covering widget choices, list patterns, and context menu support
- Update README.md with new features including context menu shortcuts, poll integration, and accessibility improvements

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Storm Dragon
2025-07-23 03:45:47 -04:00
parent 8b9187e23f
commit cd535aebdf
8 changed files with 1816 additions and 992 deletions
+79
View File
@@ -14,6 +14,7 @@ Bifrost is a fully accessible fediverse client built with PySide6, designed spec
2. **UI Event Handlers**: Avoid circular event chains (A triggers B which triggers A)
3. **Timeline Operations**: Coordinate refresh calls and state changes to prevent conflicts
4. **Lifecycle Events**: Ensure shutdown, close, and quit events don't overlap
4. **Design**: Ensure single point of truth is used as much as possible through out the code. This means functionality should not be duplicated across files.
### Required Patterns for Event-Heavy Operations
@@ -611,4 +612,82 @@ Examples of forbidden practices:
- Shortened usernames or descriptions
- Abbreviated profile information
### Screen Reader Accessibility Guidelines
#### Widget Choice and Layout
**DO:**
- Use `QTextEdit` (read-only) for all text content that needs to be accessible
- Use `QListWidget` with fake header items for navigable lists
- Set proper `setAccessibleName()` on all interactive widgets
- Enable keyboard navigation with `Qt.TextSelectableByKeyboard`
**DON'T:**
- Use `QLabel` for important information - screen readers often skip labels
- Use disabled `QCheckBox` or `QRadioButton` widgets - they get skipped
- Create single-item lists without fake headers - they're hard to navigate
#### Accessible List Pattern
For any list that users need to navigate (polls, favorites, boosts):
```python
list_widget = QListWidget()
list_widget.setAccessibleName("Descriptive List Name")
# Add fake header for single-item navigation
fake_header = QListWidgetItem("List title:")
fake_header.setFlags(Qt.ItemIsEnabled) # Not selectable
list_widget.addItem(fake_header)
# Add actual items
for item in items:
list_item = QListWidgetItem(item_text)
list_item.setData(Qt.UserRole, item_data)
list_widget.addItem(list_item)
```
#### Context Menu Accessibility
**Required keyboard shortcuts for context menus:**
- Applications key (`Qt.Key_Menu`)
- Shift+F10 (`Qt.Key_F10` with `Qt.ShiftModifier`)
Both must trigger: `self.customContextMenuRequested.emit(center)`
#### Interactive vs Display Content
**For expired/completed interactive elements (polls, forms):**
- Replace disabled controls with accessible display lists
- Show results using the accessible list pattern
- Never leave users with disabled widgets they can't interact with
**For live interactive elements:**
- Use standard Qt controls (`QCheckBox`, `QRadioButton`, `QPushButton`)
- Ensure all controls have meaningful `setAccessibleName()`
- Provide clear feedback for actions
#### Information Display Priority
**All important information must be in accessible widgets:**
1. **Primary location:** `QTextEdit` (read-only, keyboard selectable)
2. **Secondary location:** `QListWidget` items
3. **Never primary:** `QLabel` widgets (often skipped)
**Example of accessible content display:**
```python
content_text = QTextEdit()
content_text.setAccessibleName("Full Post Details")
content_text.setPlainText(f"Author: @{username}\n\nContent:\n{post_content}\n\nMetadata:\n{details}")
content_text.setReadOnly(True)
content_text.setTextInteractionFlags(Qt.TextSelectableByKeyboard | Qt.TextSelectableByMouse)
```
#### Dialog and Tab Design
**Avoid content duplication:**
- Don't show the same information in multiple places
- Use tabs to organize different types of information
- Keep essential stats/counts at the top level
- Put detailed content in dedicated tabs
**Tab organization example:**
- Statistics: Brief counts at dialog top
- Poll tab: Interactive voting or accessible results list
- Content tab: Full post details in accessible text widget
- Interaction tabs: Who favorited/boosted in accessible lists
This document serves as the comprehensive development guide for Bifrost, ensuring all accessibility, functionality, and architectural decisions are preserved and can be referenced throughout development.
+8 -1
View File
@@ -28,7 +28,7 @@ This project was created through "vibe coding" - a collaborative development app
- **User Profile Viewer**: Comprehensive profile viewing with bio, fields, recent posts, and social actions
- **Social Features**: Follow/unfollow, block/unblock, and mute/unmute users directly from profiles
- **Media Uploads**: Attach images, videos, and audio files with accessibility-compliant alt text
- **Post Details**: Press Enter on any post to see detailed interaction information
- **Post Details**: Press Enter on any post to see detailed interaction information with poll integration
- **Thread Expansion**: Full conversation context fetching for complete thread viewing
- **Blocked/Muted Management**: Dedicated tabs for managing blocked and muted users
- **Custom Emoji Support**: Instance-specific emoji support with caching
@@ -94,6 +94,7 @@ Bifrost includes a sophisticated sound system with:
- **Ctrl+U**: Open URLs from selected post in browser
- **Ctrl+Shift+B**: Block user who authored selected post
- **Ctrl+Shift+M**: Mute user who authored selected post
- **Applications Key/Shift+F10**: Open context menu with all post actions
### Navigation
- **Arrow Keys**: Navigate through posts
@@ -263,6 +264,7 @@ Bifrost includes comprehensive poll support with full accessibility:
- **Vote Submission**: Submit votes with accessible button controls
### Viewing Results
- **Integrated Display**: Poll results shown in post details dialog as first tab
- **Automatic Display**: Results shown immediately after voting or for expired polls
- **Navigable List**: Vote counts and percentages in an accessible list widget
- **Arrow Key Navigation**: Review each option's results individually
@@ -273,6 +275,8 @@ Bifrost includes comprehensive poll support with full accessibility:
- **Keyboard Only**: Complete functionality without mouse interaction
- **Clear Announcements**: Descriptive text for poll status and options
- **Focus Management**: Proper tab order and focus placement
- **Accessible Results**: Poll results displayed using accessible QListWidget pattern
- **Context Menu Support**: All poll actions available via context menu shortcuts
- **Error Handling**: Accessible feedback for voting errors (duplicate votes, etc.)
## Accessibility Features
@@ -283,6 +287,9 @@ Bifrost includes comprehensive poll support with full accessibility:
- Accessible names and descriptions for all controls
- Thread expansion/collapse with audio feedback
- Poll creation and voting with full accessibility support
- Context menu support with Applications key and Shift+F10
- Accessible content display using QTextEdit for complex information
- Private message conversations with proper threading
### Known Qt Display Quirk
+8
View File
@@ -156,12 +156,20 @@ class ActivityPubClient:
data['spoiler_text'] = content_warning
if in_reply_to_id:
data['in_reply_to_id'] = in_reply_to_id
self.logger.debug(f"Posting reply to {in_reply_to_id} with visibility {visibility}")
if media_ids:
data['media_ids'] = media_ids
if poll:
data['poll'] = poll
try:
return self._make_request('POST', '/api/v1/statuses', data=data)
except Exception as e:
if in_reply_to_id and "404" in str(e):
self.logger.error(f"Reply target {in_reply_to_id} not found (404), may have been deleted")
raise Exception(f"The post you're replying to may have been deleted: {e}")
else:
raise
def delete_status(self, status_id: str) -> Dict:
"""Delete a status"""
+341 -137
View File
@@ -3,8 +3,15 @@ Main application window for Bifrost
"""
from PySide6.QtWidgets import (
QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
QLabel, QMenuBar, QStatusBar, QPushButton, QTabWidget
QMainWindow,
QWidget,
QVBoxLayout,
QHBoxLayout,
QLabel,
QMenuBar,
QStatusBar,
QPushButton,
QTabWidget,
)
from PySide6.QtCore import Qt, Signal, QTimer
from PySide6.QtGui import QKeySequence, QAction, QTextCursor
@@ -32,7 +39,7 @@ class MainWindow(QMainWindow):
super().__init__()
self.settings = SettingsManager()
self.account_manager = AccountManager(self.settings)
self.logger = logging.getLogger('bifrost.main')
self.logger = logging.getLogger("bifrost.main")
# Auto-refresh tracking
self.last_activity_time = time.time()
@@ -45,10 +52,12 @@ class MainWindow(QMainWindow):
self.setup_ui()
# Initialize centralized managers after timeline is created
timeline_sound_manager = getattr(self.timeline, 'sound_manager', None)
timeline_sound_manager = getattr(self.timeline, "sound_manager", None)
# Sound coordination - single point of truth for all audio events
self.sound_coordinator = SoundCoordinator(timeline_sound_manager) if timeline_sound_manager else None
self.sound_coordinator = (
SoundCoordinator(timeline_sound_manager) if timeline_sound_manager else None
)
# Error management - single point of truth for error handling
self.error_manager = ErrorManager(self, self.sound_coordinator)
@@ -369,7 +378,7 @@ class MainWindow(QMainWindow):
"""Mark that initial loading is complete"""
self.is_initial_load = False
# Enable notifications on the timeline
if hasattr(self.timeline, 'enable_notifications'):
if hasattr(self.timeline, "enable_notifications"):
self.timeline.enable_notifications()
def keyPressEvent(self, event):
@@ -383,7 +392,7 @@ class MainWindow(QMainWindow):
self.update_refresh_mode()
# Skip if auto-refresh is disabled
if not self.settings.get_bool('general', 'auto_refresh_enabled', True):
if not self.settings.get_bool("general", "auto_refresh_enabled", True):
return
# Skip if no account is active
@@ -395,7 +404,9 @@ class MainWindow(QMainWindow):
return
# Get refresh interval from settings
refresh_interval = self.settings.get_int('general', 'timeline_refresh_interval', 300)
refresh_interval = self.settings.get_int(
"general", "timeline_refresh_interval", 300
)
# Skip if streaming mode (interval = 0)
if refresh_interval == 0:
@@ -405,13 +416,17 @@ class MainWindow(QMainWindow):
time_since_activity = time.time() - self.last_activity_time
required_idle_time = refresh_interval + 10 # refresh_rate + 10 seconds
self.logger.debug(f"Auto-refresh check: {time_since_activity:.1f}s since activity, need {required_idle_time}s idle")
self.logger.debug(
f"Auto-refresh check: {time_since_activity:.1f}s since activity, need {required_idle_time}s idle"
)
if time_since_activity >= required_idle_time:
self.logger.debug("Auto-refresh condition met, triggering refresh")
self.auto_refresh_timeline()
else:
self.logger.debug(f"Auto-refresh skipped: need {required_idle_time - time_since_activity:.1f}s more idle time")
self.logger.debug(
f"Auto-refresh skipped: need {required_idle_time - time_since_activity:.1f}s more idle time"
)
def auto_refresh_timeline(self):
"""Automatically refresh the timeline - DELEGATED TO TIMELINE"""
@@ -437,12 +452,14 @@ class MainWindow(QMainWindow):
def update_refresh_mode(self):
"""Update refresh mode based on settings (0 = streaming, >0 = polling)"""
# Prevent infinite recursion
if hasattr(self, '_updating_refresh_mode') and self._updating_refresh_mode:
if hasattr(self, "_updating_refresh_mode") and self._updating_refresh_mode:
return
self._updating_refresh_mode = True
try:
refresh_interval = self.settings.get_int('general', 'timeline_refresh_interval', 300)
refresh_interval = self.settings.get_int(
"general", "timeline_refresh_interval", 300
)
should_stream = refresh_interval == 0
# Check if server supports streaming
@@ -450,20 +467,30 @@ class MainWindow(QMainWindow):
active_account = self.account_manager.get_active_account()
if active_account:
# Disable streaming for known non-supporting servers
server_supports_streaming = self.check_server_streaming_support(active_account.instance_url)
server_supports_streaming = self.check_server_streaming_support(
active_account.instance_url
)
if not server_supports_streaming:
self.logger.info("Server does not support streaming, switching to polling")
self.logger.info(
"Server does not support streaming, switching to polling"
)
should_stream = False
# Set a reasonable polling interval instead
if refresh_interval == 0:
self.logger.debug("Using 2-minute polling instead of streaming")
self.logger.debug(
"Using 2-minute polling instead of streaming"
)
# Don't save this change to settings, just use it temporarily
refresh_interval = 120 # 2 minutes
# Only log refresh interval when it changes
if (refresh_interval != self._last_logged_refresh_interval or
should_stream != self._last_logged_stream_mode):
self.logger.debug(f"Refresh interval = {refresh_interval} seconds, should_stream = {should_stream}")
if (
refresh_interval != self._last_logged_refresh_interval
or should_stream != self._last_logged_stream_mode
):
self.logger.debug(
f"Refresh interval = {refresh_interval} seconds, should_stream = {should_stream}"
)
self._last_logged_refresh_interval = refresh_interval
self._last_logged_stream_mode = should_stream
@@ -481,7 +508,7 @@ class MainWindow(QMainWindow):
try:
# Quick URL-based checks for known non-streaming servers
url_lower = instance_url.lower()
if 'gotosocial' in url_lower:
if "gotosocial" in url_lower:
self.logger.debug("GoToSocial detected in URL - no streaming support")
return False
@@ -489,7 +516,11 @@ class MainWindow(QMainWindow):
active_account = self.account_manager.get_active_account()
if active_account:
client = self.account_manager.get_client_for_active_account()
if client and hasattr(client, 'streaming_supported') and not client.streaming_supported:
if (
client
and hasattr(client, "streaming_supported")
and not client.streaming_supported
):
self.logger.debug("Server previously failed streaming attempts")
return False
@@ -497,8 +528,8 @@ class MainWindow(QMainWindow):
if client:
try:
instance_info = client.get_instance_info()
version = instance_info.get('version', '').lower()
if 'gotosocial' in version:
version = instance_info.get("version", "").lower()
if "gotosocial" in version:
self.logger.debug(f"GoToSocial detected via API: {version}")
return False
except Exception as e:
@@ -519,24 +550,30 @@ class MainWindow(QMainWindow):
if not active_account:
self.logger.warning("No active account, cannot start streaming")
return
self.logger.debug(f"Active account: {active_account.username}@{active_account.instance_url}")
self.logger.debug(
f"Active account: {active_account.username}@{active_account.instance_url}"
)
try:
# Stop any existing streaming
self.stop_streaming_mode()
# Create streaming client if needed
if not self.streaming_client or self.streaming_client.instance_url != active_account.instance_url:
self.streaming_client = self.account_manager.get_client_for_active_account()
if (
not self.streaming_client
or self.streaming_client.instance_url != active_account.instance_url
):
self.streaming_client = (
self.account_manager.get_client_for_active_account()
)
if not self.streaming_client:
return
# Start streaming for current timeline type
timeline_type = self.timeline.timeline_type
if timeline_type in ['home', 'local', 'federated', 'notifications']:
if timeline_type in ["home", "local", "federated", "notifications"]:
self.streaming_client.start_streaming(
timeline_type,
callback=self.handle_streaming_event
timeline_type, callback=self.handle_streaming_event
)
self.streaming_mode = True
self.logger.info(f"Started streaming for {timeline_type} timeline")
@@ -559,13 +596,13 @@ class MainWindow(QMainWindow):
def handle_streaming_event(self, event_type: str, data):
"""Handle real-time streaming events"""
try:
if event_type == 'new_post':
if event_type == "new_post":
# New post received via streaming
self.add_streaming_post(data)
elif event_type == 'new_notification':
elif event_type == "new_notification":
# New notification received
self.handle_streaming_notification(data)
elif event_type == 'delete_post':
elif event_type == "delete_post":
# Post deleted
self.remove_streaming_post(data)
except Exception as e:
@@ -579,51 +616,64 @@ class MainWindow(QMainWindow):
# Trigger a refresh to show new content
# In future, could add the post directly to avoid full refresh
if not self.is_initial_load:
self.logger.debug(f"New streaming post received for {current_timeline} timeline")
self.logger.debug(
f"New streaming post received for {current_timeline} timeline"
)
# Show notification of new content
timeline_name = {
'home': 'home timeline',
'local': 'local timeline',
'federated': 'federated timeline'
}.get(current_timeline, 'timeline')
"home": "home timeline",
"local": "local timeline",
"federated": "federated timeline",
}.get(current_timeline, "timeline")
if hasattr(self.timeline, 'notification_manager') and not self.timeline.skip_notifications:
if (
hasattr(self.timeline, "notification_manager")
and not self.timeline.skip_notifications
):
self.timeline.notification_manager.notify_new_content(timeline_name)
# Play sound for new content
if hasattr(self.timeline, 'sound_manager'):
if hasattr(self.timeline, "sound_manager"):
self.timeline.sound_manager.play_timeline_update()
# Try to add streaming post directly instead of full refresh
self.logger.debug("Adding streaming post directly to timeline")
try:
from models.post import Post
streaming_post = Post.from_api_dict(post_data)
self.timeline.add_streaming_post_to_timeline(streaming_post)
except Exception as e:
self.logger.warning(f"Failed to add streaming post directly, falling back to refresh: {e}")
self.logger.warning(
f"Failed to add streaming post directly, falling back to refresh: {e}"
)
self.timeline.refresh(preserve_position=True)
def handle_streaming_notification(self, notification_data):
"""Handle new notifications received via streaming"""
self.logger.debug(f"New streaming notification received: {notification_data.get('type', 'unknown')}")
self.logger.debug(
f"New streaming notification received: {notification_data.get('type', 'unknown')}"
)
# If we're on the notifications timeline, refresh to show the new notification
if self.timeline.timeline_type == 'notifications':
if self.timeline.timeline_type == "notifications":
self.logger.debug("Refreshing notifications timeline for new notification")
self.timeline.refresh(preserve_position=True)
# Play appropriate notification sound based on type
if (hasattr(self.timeline, 'sound_manager') and not self.timeline.skip_notifications):
notification_type = notification_data.get('type', 'notification')
if notification_type == 'mention':
if (
hasattr(self.timeline, "sound_manager")
and not self.timeline.skip_notifications
):
notification_type = notification_data.get("type", "notification")
if notification_type == "mention":
self.timeline.sound_manager.play_mention()
elif notification_type == 'reblog':
elif notification_type == "reblog":
self.timeline.sound_manager.play_boost()
elif notification_type == 'favourite':
elif notification_type == "favourite":
self.timeline.sound_manager.play_favorite()
elif notification_type == 'follow':
elif notification_type == "follow":
self.timeline.sound_manager.play_follow()
else:
self.timeline.sound_manager.play_notification()
@@ -645,19 +695,18 @@ class MainWindow(QMainWindow):
# Use centralized PostManager instead of duplicate logic
success = self.post_manager.create_post(
content=post_data.get('content', ''),
visibility=post_data.get('visibility', 'public'),
content_type=post_data.get('content_type', 'text/plain'),
content_warning=post_data.get('content_warning'),
in_reply_to_id=post_data.get('in_reply_to_id'),
poll=post_data.get('poll'),
media_ids=post_data.get('media_ids')
content=post_data.get("content", ""),
visibility=post_data.get("visibility", "public"),
content_type=post_data.get("content_type", "text/plain"),
content_warning=post_data.get("content_warning"),
in_reply_to_id=post_data.get("in_reply_to_id"),
poll=post_data.get("poll"),
media_ids=post_data.get("media_ids"),
)
if not success:
self.error_manager.handle_validation_error(
"Failed to start post submission",
context="post_creation"
"Failed to start post submission", context="post_creation"
)
def on_post_success(self, result_data):
@@ -666,7 +715,7 @@ class MainWindow(QMainWindow):
self.error_manager.show_success_message(
"Post sent successfully!",
context="post_creation",
play_sound=False # PostManager already plays sound
play_sound=False, # PostManager already plays sound
)
# Refresh timeline to show the new post
@@ -676,8 +725,7 @@ class MainWindow(QMainWindow):
"""Handle failed post submission - CENTRALIZED VIA POSTMANAGER"""
# Note: Error sound is handled by PostManager to avoid duplication
self.error_manager.handle_api_error(
f"Post failed: {error_message}",
context="post_creation"
f"Post failed: {error_message}", context="post_creation"
)
def show_settings(self):
@@ -689,7 +737,7 @@ class MainWindow(QMainWindow):
def on_settings_changed(self):
"""Handle settings changes"""
# Reload sound manager with new settings
if hasattr(self.timeline, 'sound_manager'):
if hasattr(self.timeline, "sound_manager"):
self.timeline.sound_manager.reload_settings()
# Check if refresh mode changed
@@ -713,15 +761,40 @@ class MainWindow(QMainWindow):
def switch_timeline(self, index, from_tab_change=False):
"""Switch to timeline by index with loading feedback"""
timeline_names = ["Home", "Messages", "Notifications", "Local", "Federated", "Bookmarks", "Followers", "Following", "Blocked", "Muted"]
timeline_types = ["home", "conversations", "notifications", "local", "federated", "bookmarks", "followers", "following", "blocked", "muted"]
timeline_names = [
"Home",
"Messages",
"Notifications",
"Local",
"Federated",
"Bookmarks",
"Followers",
"Following",
"Blocked",
"Muted",
]
timeline_types = [
"home",
"conversations",
"notifications",
"local",
"federated",
"bookmarks",
"followers",
"following",
"blocked",
"muted",
]
if 0 <= index < len(timeline_names):
timeline_name = timeline_names[index]
timeline_type = timeline_types[index]
# Prevent duplicate calls for the same timeline
if hasattr(self, '_current_timeline_switching') and self._current_timeline_switching:
if (
hasattr(self, "_current_timeline_switching")
and self._current_timeline_switching
):
return
self._current_timeline_switching = True
@@ -747,13 +820,18 @@ class MainWindow(QMainWindow):
except Exception as e:
# Error feedback
if hasattr(self.timeline, 'sound_manager'):
if hasattr(self.timeline, "sound_manager"):
self.timeline.sound_manager.play_error()
self.status_bar.showMessage(f"Failed to load {timeline_name} timeline: {str(e)}", 3000)
self.status_bar.showMessage(
f"Failed to load {timeline_name} timeline: {str(e)}", 3000
)
finally:
# Reset the flag after a brief delay to allow the operation to complete
from PySide6.QtCore import QTimer
QTimer.singleShot(100, lambda: setattr(self, '_current_timeline_switching', False))
QTimer.singleShot(
100, lambda: setattr(self, "_current_timeline_switching", False)
)
def get_selected_post(self):
"""Get the currently selected post from timeline"""
@@ -812,7 +890,7 @@ class MainWindow(QMainWindow):
"Welcome to Bifrost! You need to add a fediverse account to get started.\n\n"
"Would you like to add an account now?",
QMessageBox.Yes | QMessageBox.No,
QMessageBox.Yes
QMessageBox.Yes,
)
if result == QMessageBox.Yes:
@@ -837,22 +915,106 @@ class MainWindow(QMainWindow):
account = self.account_manager.get_account_by_id(account_id)
if account:
self.update_status_label()
self.status_bar.showMessage(f"Switched to {account.get_display_text()}", 2000)
self.status_bar.showMessage(
f"Switched to {account.get_display_text()}", 2000
)
# Refresh timeline with new account
self.timeline.request_post_action_refresh("account_action")
def reply_to_post(self, post):
"""Reply to a specific post"""
"""Reply to a specific post or conversation"""
dialog = ComposeDialog(self.account_manager, self)
# Pre-fill with reply mention using full fediverse handle
dialog.text_edit.setPlainText(f"@{post.account.acct} ")
# Move cursor to end
cursor = dialog.text_edit.textCursor()
cursor.movePosition(QTextCursor.MoveOperation.End)
dialog.text_edit.setTextCursor(cursor)
dialog.post_sent.connect(lambda data: self.on_post_sent({**data, 'in_reply_to_id': post.id}))
# Use the new setup_reply method to handle visibility and text
dialog.setup_reply(post)
# Handle different types of replies
if hasattr(post, "conversation") and post.conversation:
# This is a conversation - send as direct message to participants
dialog.post_sent.connect(
lambda data: self.on_conversation_reply_sent(post, data)
)
else:
# This is a regular post - reply normally
dialog.post_sent.connect(
lambda data: self.on_post_sent({**data, "in_reply_to_id": post.id})
)
dialog.exec()
def on_conversation_reply_sent(self, conversation_post, data):
"""Handle sending a reply to a conversation"""
try:
active_account = self.account_manager.get_active_account()
if not active_account:
return
client = self.account_manager.get_client_for_active_account()
if not client:
return
# Get conversation participants
participants = []
if (
hasattr(conversation_post, "conversation")
and conversation_post.conversation
):
for account in conversation_post.conversation.accounts:
# Don't include ourselves in the mention
if account.acct != active_account.username:
participants.append(f"@{account.acct}")
# Add participants to the message content if not already mentioned
content = data.get("content", "")
for participant in participants:
if participant not in content:
content = f"{participant} {content}"
# Check if this is a Pleroma chat conversation
if (
hasattr(conversation_post.conversation, "chat_id")
and conversation_post.conversation.chat_id
):
# Use Pleroma chat API
try:
result = client.send_pleroma_chat_message(
conversation_post.conversation.chat_id, content.strip()
)
self.logger.info(
f"Sent Pleroma chat message to conversation {conversation_post.conversation.chat_id}"
)
self.post_manager._handle_post_success(result)
# Refresh timeline to show the new message
self.timeline.request_post_action_refresh("conversation_reply")
except Exception as e:
self.logger.error(f"Failed to send Pleroma chat message: {e}")
# Fall back to regular direct message
self.send_direct_message_reply(client, content, data)
else:
# Send as regular direct message
self.send_direct_message_reply(client, content, data)
except Exception as e:
self.logger.error(f"Failed to send conversation reply: {e}")
self.post_manager._handle_post_failed(str(e))
def send_direct_message_reply(self, client, content, data):
"""Send a direct message reply to conversation participants"""
try:
# Create a direct message post
result = client.post_status(
content=content,
visibility="direct",
content_warning=data.get("content_warning"),
media_ids=data.get("media_ids", []),
)
self.logger.info("Sent direct message reply to conversation")
self.post_manager._handle_post_success(result)
# Refresh timeline to show the new message
self.timeline.request_post_action_refresh("conversation_reply")
except Exception as e:
self.logger.error(f"Failed to send direct message reply: {e}")
self.post_manager._handle_post_failed(str(e))
def boost_post(self, post):
"""Boost/unboost a post"""
active_account = self.account_manager.get_active_account()
@@ -870,7 +1032,7 @@ class MainWindow(QMainWindow):
client.reblog_status(post.id)
self.status_bar.showMessage("Post boosted", 2000)
# Play boost sound for successful boost
if hasattr(self.timeline, 'sound_manager'):
if hasattr(self.timeline, "sound_manager"):
self.logger.debug("Playing boost sound for user boost action")
self.timeline.sound_manager.play_boost()
# Refresh timeline to show updated state
@@ -895,7 +1057,7 @@ class MainWindow(QMainWindow):
client.favourite_status(post.id)
self.status_bar.showMessage("Post favorited", 2000)
# Play favorite sound for successful favorite
if hasattr(self.timeline, 'sound_manager'):
if hasattr(self.timeline, "sound_manager"):
self.logger.debug("Playing favorite sound for user favorite action")
self.timeline.sound_manager.play_favorite()
# Refresh timeline to show updated state
@@ -912,26 +1074,28 @@ class MainWindow(QMainWindow):
# Create User object from Account data
account = post.account
account_data = {
'id': account.id,
'username': account.username,
'acct': account.acct,
'display_name': account.display_name,
'note': account.note,
'url': account.url,
'avatar': account.avatar,
'avatar_static': account.avatar_static,
'header': account.header,
'header_static': account.header_static,
'locked': account.locked,
'bot': account.bot,
'discoverable': account.discoverable,
'group': account.group,
'created_at': account.created_at.isoformat() if account.created_at else None,
'followers_count': account.followers_count,
'following_count': account.following_count,
'statuses_count': account.statuses_count,
'fields': [], # Will be loaded from API
'emojis': [] # Will be loaded from API
"id": account.id,
"username": account.username,
"acct": account.acct,
"display_name": account.display_name,
"note": account.note,
"url": account.url,
"avatar": account.avatar,
"avatar_static": account.avatar_static,
"header": account.header,
"header_static": account.header_static,
"locked": account.locked,
"bot": account.bot,
"discoverable": account.discoverable,
"group": account.group,
"created_at": (
account.created_at.isoformat() if account.created_at else None
),
"followers_count": account.followers_count,
"following_count": account.following_count,
"statuses_count": account.statuses_count,
"fields": [], # Will be loaded from API
"emojis": [], # Will be loaded from API
}
user = User.from_api_dict(account_data)
@@ -941,29 +1105,34 @@ class MainWindow(QMainWindow):
account_manager=self.account_manager,
sound_manager=self.timeline.sound_manager,
initial_user=user,
parent=self
parent=self,
)
dialog.exec()
except Exception as e:
self.status_bar.showMessage(f"Error opening profile: {str(e)}", 3000)
if hasattr(self.timeline, 'sound_manager'):
if hasattr(self.timeline, "sound_manager"):
self.timeline.sound_manager.play_error()
def update_status_label(self):
"""Update the status label with current account info"""
active_account = self.account_manager.get_active_account()
if active_account:
self.status_label.setText(f"Connected as {active_account.get_display_text()}")
self.status_label.setText(
f"Connected as {active_account.get_display_text()}"
)
else:
self.status_label.setText("No account connected")
def quit_application(self):
"""Quit the application with shutdown sound"""
self._shutdown_sound_played = True # Mark that we're handling the shutdown sound
if hasattr(self.timeline, 'sound_manager'):
self._shutdown_sound_played = (
True # Mark that we're handling the shutdown sound
)
if hasattr(self.timeline, "sound_manager"):
self.timeline.sound_manager.play_shutdown()
# Wait briefly for sound to start playing
from PySide6.QtCore import QTimer
QTimer.singleShot(500, self.close)
else:
self.close()
@@ -1006,12 +1175,17 @@ class MainWindow(QMainWindow):
# Check if this is user's own post
active_account = self.account_manager.get_active_account()
if not active_account or not hasattr(post, 'account'):
if not active_account or not hasattr(post, "account"):
self.status_bar.showMessage("Cannot delete: No active account", 2000)
return
is_own_post = (post.account.username == active_account.username and
post.account.acct.split('@')[-1] == active_account.instance_url.replace('https://', '').replace('http://', ''))
is_own_post = (
post.account.username == active_account.username
and post.account.acct.split("@")[-1]
== active_account.instance_url.replace("https://", "").replace(
"http://", ""
)
)
if not is_own_post:
self.status_bar.showMessage("Cannot delete: Not your post", 2000)
@@ -1023,9 +1197,9 @@ class MainWindow(QMainWindow):
result = QMessageBox.question(
self,
"Delete Post",
f"Are you sure you want to delete this post?\n\n\"{content_preview}\"",
f'Are you sure you want to delete this post?\n\n"{content_preview}"',
QMessageBox.Yes | QMessageBox.No,
QMessageBox.No
QMessageBox.No,
)
if result == QMessageBox.Yes:
@@ -1044,12 +1218,17 @@ class MainWindow(QMainWindow):
"""Edit a post"""
# Check if this is user's own post
active_account = self.account_manager.get_active_account()
if not active_account or not hasattr(post, 'account'):
if not active_account or not hasattr(post, "account"):
self.status_bar.showMessage("Cannot edit: No active account", 2000)
return
is_own_post = (post.account.username == active_account.username and
post.account.acct.split('@')[-1] == active_account.instance_url.replace('https://', '').replace('http://', ''))
is_own_post = (
post.account.username == active_account.username
and post.account.acct.split("@")[-1]
== active_account.instance_url.replace("https://", "").replace(
"http://", ""
)
)
if not is_own_post:
self.status_bar.showMessage("Cannot edit: Not your post", 2000)
@@ -1070,10 +1249,10 @@ class MainWindow(QMainWindow):
return
client.edit_status(
post.id,
content=data['content'],
visibility=data['visibility'],
content_type=data.get('content_type', 'text/plain'),
content_warning=data['content_warning']
content=data["content"],
visibility=data["visibility"],
content_type=data.get("content_type", "text/plain"),
content_warning=data["content_warning"],
)
self.status_bar.showMessage("Post edited successfully", 2000)
# Refresh timeline to show edited post
@@ -1087,7 +1266,7 @@ class MainWindow(QMainWindow):
def follow_user(self, post):
"""Follow a user"""
active_account = self.account_manager.get_active_account()
if not active_account or not hasattr(post, 'account'):
if not active_account or not hasattr(post, "account"):
self.status_bar.showMessage("Cannot follow: No active account", 2000)
return
@@ -1099,7 +1278,7 @@ class MainWindow(QMainWindow):
username = post.account.display_name or post.account.username
self.status_bar.showMessage(f"Followed {username}", 2000)
# Play follow sound for successful follow
if hasattr(self.timeline, 'sound_manager'):
if hasattr(self.timeline, "sound_manager"):
self.timeline.sound_manager.play_follow()
except Exception as e:
self.status_bar.showMessage(f"Follow failed: {str(e)}", 3000)
@@ -1107,7 +1286,7 @@ class MainWindow(QMainWindow):
def unfollow_user(self, post):
"""Unfollow a user"""
active_account = self.account_manager.get_active_account()
if not active_account or not hasattr(post, 'account'):
if not active_account or not hasattr(post, "account"):
self.status_bar.showMessage("Cannot unfollow: No active account", 2000)
return
@@ -1119,14 +1298,21 @@ class MainWindow(QMainWindow):
username = post.account.display_name or post.account.username
self.status_bar.showMessage(f"Unfollowed {username}", 2000)
# Play unfollow sound for successful unfollow
if hasattr(self.timeline, 'sound_manager'):
if hasattr(self.timeline, "sound_manager"):
self.timeline.sound_manager.play_unfollow()
except Exception as e:
self.status_bar.showMessage(f"Unfollow failed: {str(e)}", 3000)
def show_manual_follow_dialog(self):
"""Show dialog to manually follow a user by @username@instance"""
from PySide6.QtWidgets import QDialog, QVBoxLayout, QLineEdit, QLabel, QDialogButtonBox, QPushButton
from PySide6.QtWidgets import (
QDialog,
QVBoxLayout,
QLineEdit,
QLabel,
QDialogButtonBox,
QPushButton,
)
dialog = QDialog(self)
dialog.setWindowTitle("Follow User")
@@ -1176,7 +1362,7 @@ class MainWindow(QMainWindow):
return
# Remove @ prefix if present
if username.startswith('@'):
if username.startswith("@"):
username = username[1:]
try:
@@ -1193,7 +1379,10 @@ class MainWindow(QMainWindow):
# Find exact match
target_account = None
for account in accounts:
if account['acct'] == username or account['username'] == username.split('@')[0]:
if (
account["acct"] == username
or account["username"] == username.split("@")[0]
):
target_account = account
break
@@ -1202,11 +1391,13 @@ class MainWindow(QMainWindow):
return
# Follow the account
client.follow_account(target_account['id'])
display_name = target_account.get('display_name') or target_account['username']
client.follow_account(target_account["id"])
display_name = (
target_account.get("display_name") or target_account["username"]
)
self.status_bar.showMessage(f"Followed {display_name}", 2000)
# Play follow sound for successful follow
if hasattr(self.timeline, 'sound_manager'):
if hasattr(self.timeline, "sound_manager"):
self.timeline.sound_manager.play_follow()
except Exception as e:
@@ -1231,13 +1422,18 @@ class MainWindow(QMainWindow):
def block_user(self, post):
"""Block a user with confirmation dialog"""
active_account = self.account_manager.get_active_account()
if not active_account or not hasattr(post, 'account'):
if not active_account or not hasattr(post, "account"):
self.status_bar.showMessage("Cannot block: No active account", 2000)
return
# Don't allow blocking yourself
is_own_post = (post.account.username == active_account.username and
post.account.acct.split('@')[-1] == active_account.instance_url.replace('https://', '').replace('http://', ''))
is_own_post = (
post.account.username == active_account.username
and post.account.acct.split("@")[-1]
== active_account.instance_url.replace("https://", "").replace(
"http://", ""
)
)
if is_own_post:
self.status_bar.showMessage("Cannot block: Cannot block yourself", 2000)
@@ -1253,7 +1449,7 @@ class MainWindow(QMainWindow):
f"Are you sure you want to block {username} ({full_username})?\n\n"
"This will prevent them from following you and seeing your posts.",
QMessageBox.Yes | QMessageBox.No,
QMessageBox.No
QMessageBox.No,
)
if result == QMessageBox.Yes:
@@ -1264,23 +1460,28 @@ class MainWindow(QMainWindow):
client.block_account(post.account.id)
self.status_bar.showMessage(f"Blocked {username}", 2000)
# Play success sound for successful block
if hasattr(self.timeline, 'sound_manager'):
if hasattr(self.timeline, "sound_manager"):
self.timeline.sound_manager.play_success()
except Exception as e:
self.status_bar.showMessage(f"Block failed: {str(e)}", 3000)
if hasattr(self.timeline, 'sound_manager'):
if hasattr(self.timeline, "sound_manager"):
self.timeline.sound_manager.play_error()
def mute_user(self, post):
"""Mute a user"""
active_account = self.account_manager.get_active_account()
if not active_account or not hasattr(post, 'account'):
if not active_account or not hasattr(post, "account"):
self.status_bar.showMessage("Cannot mute: No active account", 2000)
return
# Don't allow muting yourself
is_own_post = (post.account.username == active_account.username and
post.account.acct.split('@')[-1] == active_account.instance_url.replace('https://', '').replace('http://', ''))
is_own_post = (
post.account.username == active_account.username
and post.account.acct.split("@")[-1]
== active_account.instance_url.replace("https://", "").replace(
"http://", ""
)
)
if is_own_post:
self.status_bar.showMessage("Cannot mute: Cannot mute yourself", 2000)
@@ -1294,11 +1495,11 @@ class MainWindow(QMainWindow):
username = post.account.display_name or post.account.username
self.status_bar.showMessage(f"Muted {username}", 2000)
# Play success sound for successful mute
if hasattr(self.timeline, 'sound_manager'):
if hasattr(self.timeline, "sound_manager"):
self.timeline.sound_manager.play_success()
except Exception as e:
self.status_bar.showMessage(f"Mute failed: {str(e)}", 3000)
if hasattr(self.timeline, 'sound_manager'):
if hasattr(self.timeline, "sound_manager"):
self.timeline.sound_manager.play_error()
def closeEvent(self, event):
@@ -1307,10 +1508,13 @@ class MainWindow(QMainWindow):
self.stop_streaming_mode()
# Only play shutdown sound if not already played through quit_application
if not hasattr(self, '_shutdown_sound_played') and hasattr(self.timeline, 'sound_manager'):
if not hasattr(self, "_shutdown_sound_played") and hasattr(
self.timeline, "sound_manager"
):
self.timeline.sound_manager.play_shutdown()
# Wait briefly for sound to complete
from PySide6.QtCore import QTimer, QEventLoop
loop = QEventLoop()
QTimer.singleShot(500, loop.quit)
loop.exec()
+9 -1
View File
@@ -44,7 +44,15 @@ class PostThread(QThread):
self.post_success.emit(result)
except Exception as e:
self.post_failed.emit(str(e))
error_msg = str(e)
# Provide more user-friendly error messages
if "may have been deleted" in error_msg:
user_msg = "The post you're replying to may have been deleted. Please try replying to a different post."
elif "404" in error_msg:
user_msg = "The server could not find the target post. It may have been deleted."
else:
user_msg = error_msg
self.post_failed.emit(user_msg)
class PostManager(QObject):
+148 -48
View File
@@ -3,9 +3,19 @@ Compose post dialog for creating new posts
"""
from PySide6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QTextEdit,
QPushButton, QLabel, QDialogButtonBox, QCheckBox,
QComboBox, QGroupBox, QLineEdit, QSpinBox, QMessageBox
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
@@ -22,6 +32,7 @@ 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"""
@@ -33,7 +44,7 @@ class ComposeDialog(QDialog):
self.sound_manager = SoundManager(self.settings)
self.account_manager = account_manager
self.media_upload_widget = None
self.logger = logging.getLogger('bifrost.compose')
self.logger = logging.getLogger("bifrost.compose")
self.setup_ui()
self.setup_shortcuts()
self.load_default_settings()
@@ -54,8 +65,12 @@ class ComposeDialog(QDialog):
# 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.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)
@@ -73,18 +88,17 @@ class ComposeDialog(QDialog):
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"
])
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.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")
@@ -101,10 +115,14 @@ class ComposeDialog(QDialog):
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.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.setTabChangesFocus(
True
) # Allow Tab to exit the content warning field
self.cw_edit.hide()
options_layout.addWidget(self.cw_edit)
@@ -127,7 +145,9 @@ class ComposeDialog(QDialog):
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.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)...")
@@ -143,7 +163,9 @@ class ComposeDialog(QDialog):
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.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
@@ -174,7 +196,9 @@ class ComposeDialog(QDialog):
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;")
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}")
@@ -182,7 +206,9 @@ class ComposeDialog(QDialog):
# 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;")
error_placeholder.setStyleSheet(
"color: #888; font-style: italic; padding: 10px;"
)
layout.addWidget(error_placeholder)
# Button box
@@ -219,11 +245,62 @@ class ComposeDialog(QDialog):
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')
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 setup_reply(self, post):
"""Configure dialog for replying to a specific post or conversation"""
# Handle conversations differently
if hasattr(post, "conversation") and post.conversation:
# This is a conversation - pre-fill with all participants
participants = []
active_account = self.account_manager.get_active_account()
current_username = active_account.username if active_account else None
for account in post.conversation.accounts:
# Don't include ourselves in the mention
if account.acct != current_username:
participants.append(f"@{account.acct}")
if participants:
mention_text = " ".join(participants) + " "
self.text_edit.setPlainText(mention_text)
# Set visibility to Direct Message for conversations
self.visibility_combo.setCurrentText("Direct Message")
self.logger.info(
"Reply visibility set to Direct Message for conversation reply"
)
else:
# Regular post reply
# Pre-fill with reply mention using full fediverse handle
self.text_edit.setPlainText(f"@{post.account.acct} ")
# Set appropriate visibility based on original post
if hasattr(post, "visibility"):
if post.visibility == "direct":
# For direct messages, set to Direct Message and make it prominent
self.visibility_combo.setCurrentText("Direct Message")
self.logger.info(
f"Reply visibility set to Direct Message for DM reply"
)
elif post.visibility == "private":
# For followers-only posts, default to followers-only
self.visibility_combo.setCurrentText("Followers Only")
self.logger.info(
f"Reply visibility set to Followers Only for private post reply"
)
# For public/unlisted posts, keep the current default (usually public)
# Move cursor to end
cursor = self.text_edit.textCursor()
cursor.movePosition(cursor.MoveOperation.End)
self.text_edit.setTextCursor(cursor)
def toggle_content_warning(self, enabled: bool):
"""Toggle content warning field visibility"""
if enabled:
@@ -260,7 +337,9 @@ class ComposeDialog(QDialog):
if char_count > 500:
self.char_count_label.setAccessibleDescription("Character limit exceeded")
else:
self.char_count_label.setAccessibleDescription(f"{500 - char_count} characters remaining")
self.char_count_label.setAccessibleDescription(
f"{500 - char_count} characters remaining"
)
def send_post(self):
"""Send the post"""
@@ -271,7 +350,9 @@ class ComposeDialog(QDialog):
# 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.")
QMessageBox.warning(
self, "No Account", "Please add an account before posting."
)
return
# Get post settings
@@ -280,7 +361,7 @@ class ComposeDialog(QDialog):
"Public": "public",
"Unlisted": "unlisted",
"Followers Only": "private",
"Direct Message": "direct"
"Direct Message": "direct",
}
visibility = visibility_map.get(visibility_text, "public")
@@ -302,14 +383,17 @@ class ComposeDialog(QDialog):
# Validate poll (need at least 2 options)
if len(poll_options) < 2:
QMessageBox.warning(self, "Invalid Poll", "Polls need at least 2 options.")
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()
"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
@@ -324,13 +408,13 @@ class ComposeDialog(QDialog):
# 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
"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
@@ -350,7 +434,7 @@ class ComposeDialog(QDialog):
# Get current user's account ID
current_user = client.verify_credentials()
current_account_id = current_user['id']
current_account_id = current_user["id"]
# Collect usernames from multiple sources
usernames = set()
@@ -361,10 +445,14 @@ class ComposeDialog(QDialog):
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', '')
full_handle = account.get("acct", "")
if not full_handle:
username = account.get('username', '')
domain = account.get('url', '').split('/')[2] if account.get('url') else ''
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:
@@ -380,10 +468,14 @@ class ComposeDialog(QDialog):
followers = client.get_followers(current_account_id, limit=100)
for follower in followers:
# Use full fediverse handle
full_handle = follower.get('acct', '')
full_handle = follower.get("acct", "")
if not full_handle:
username = follower.get('username', '')
domain = follower.get('url', '').split('/')[2] if follower.get('url') else ''
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:
@@ -399,10 +491,14 @@ class ComposeDialog(QDialog):
following = client.get_following(current_account_id, limit=100)
for account in following:
# Use full fediverse handle
full_handle = account.get('acct', '')
full_handle = account.get("acct", "")
if not full_handle:
username = account.get('username', '')
domain = account.get('url', '').split('/')[2] if account.get('url') else ''
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:
@@ -437,13 +533,17 @@ class ComposeDialog(QDialog):
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
"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()
data["media_ids"] = self.media_upload_widget.get_media_ids()
return data
+263 -45
View File
@@ -3,9 +3,21 @@ Post details dialog showing favorites, boosts, and other interaction details
"""
from PySide6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QLabel, QTextEdit,
QTabWidget, QListWidget, QListWidgetItem, QDialogButtonBox,
QWidget, QGroupBox, QPushButton
QDialog,
QVBoxLayout,
QHBoxLayout,
QLabel,
QTextEdit,
QTabWidget,
QListWidget,
QListWidgetItem,
QDialogButtonBox,
QWidget,
QGroupBox,
QPushButton,
QCheckBox,
QRadioButton,
QButtonGroup,
)
from PySide6.QtCore import Qt, Signal, QThread
from PySide6.QtGui import QFont
@@ -27,27 +39,24 @@ class FetchDetailsThread(QThread):
super().__init__()
self.client = client
self.post_id = post_id
self.logger = logging.getLogger('bifrost.post_details')
self.logger = logging.getLogger("bifrost.post_details")
def run(self):
"""Fetch favorites and boosts in background"""
try:
details = {
'favourited_by': [],
'reblogged_by': []
}
details = {"favourited_by": [], "reblogged_by": []}
# Fetch who favorited this post
try:
favourited_by_data = self.client.get_status_favourited_by(self.post_id)
details['favourited_by'] = favourited_by_data
details["favourited_by"] = favourited_by_data
except Exception as e:
self.logger.error(f"Failed to fetch favorites: {e}")
# Fetch who boosted this post
try:
reblogged_by_data = self.client.get_status_reblogged_by(self.post_id)
details['reblogged_by'] = reblogged_by_data
details["reblogged_by"] = reblogged_by_data
except Exception as e:
self.logger.error(f"Failed to fetch boosts: {e}")
@@ -60,12 +69,18 @@ class FetchDetailsThread(QThread):
class PostDetailsDialog(QDialog):
"""Dialog showing detailed post interaction information"""
def __init__(self, post, client: ActivityPubClient, sound_manager: SoundManager, parent=None):
vote_submitted = Signal(
object, list
) # Emitted with post and list of selected choice indices
def __init__(
self, post, client: ActivityPubClient, sound_manager: SoundManager, parent=None
):
super().__init__(parent)
self.post = post
self.client = client
self.sound_manager = sound_manager
self.logger = logging.getLogger('bifrost.post_details')
self.logger = logging.getLogger("bifrost.post_details")
self.setWindowTitle("Post Details")
self.setModal(True)
@@ -78,41 +93,27 @@ class PostDetailsDialog(QDialog):
"""Setup the post details UI"""
layout = QVBoxLayout(self)
# Post content section
content_group = QGroupBox("Post Content")
content_group.setAccessibleName("Post Content")
content_layout = QVBoxLayout(content_group)
# Author info
author_label = QLabel(f"@{self.post.account.username} ({self.post.account.display_name or self.post.account.username})")
author_label.setAccessibleName("Post Author")
author_font = QFont()
author_font.setBold(True)
author_label.setFont(author_font)
content_layout.addWidget(author_label)
# Post content
content_text = QTextEdit()
content_text.setAccessibleName("Post Content")
content_text.setPlainText(self.post.get_content_text())
content_text.setReadOnly(True)
content_text.setMaximumHeight(100)
# Enable keyboard navigation in read-only text
content_text.setTextInteractionFlags(Qt.TextSelectableByKeyboard | Qt.TextSelectableByMouse)
content_layout.addWidget(content_text)
# Stats
# Post statistics (keep just the basic stats at top)
stats_text = f"Replies: {self.post.replies_count} | Boosts: {self.post.reblogs_count} | Favorites: {self.post.favourites_count}"
stats_label = QLabel(stats_text)
stats_label.setAccessibleName("Post Statistics")
content_layout.addWidget(stats_label)
layout.addWidget(content_group)
layout.addWidget(stats_label)
# Tabs for interaction details
self.tabs = QTabWidget()
self.tabs.setAccessibleName("Interaction Details")
# Poll tab (if poll exists) - add as first tab
if hasattr(self.post, "poll") and self.post.poll:
self.poll_widget = self.create_poll_widget()
poll_tab_index = self.tabs.addTab(self.poll_widget, "Poll")
self.logger.debug(f"Added poll tab at index {poll_tab_index}")
# Content tab - always present
self.content_widget = self.create_content_widget()
content_tab_index = self.tabs.addTab(self.content_widget, "Content")
self.logger.debug(f"Added content tab at index {content_tab_index}")
# Favorites tab
self.favorites_list = QListWidget()
self.favorites_list.setAccessibleName("Users Who Favorited")
@@ -120,7 +121,9 @@ class PostDetailsDialog(QDialog):
fake_header = QListWidgetItem("Users who favorited this post:")
fake_header.setFlags(Qt.ItemIsEnabled) # Not selectable
self.favorites_list.addItem(fake_header)
self.tabs.addTab(self.favorites_list, f"Favorites ({self.post.favourites_count})")
self.tabs.addTab(
self.favorites_list, f"Favorites ({self.post.favourites_count})"
)
# Boosts tab
self.boosts_list = QListWidget()
@@ -146,7 +149,7 @@ class PostDetailsDialog(QDialog):
def load_details(self):
"""Load detailed interaction information"""
if not self.client or not hasattr(self.post, 'id'):
if not self.client or not hasattr(self.post, "id"):
self.status_label.setText("Cannot load details: No post ID or API client")
return
@@ -161,7 +164,7 @@ class PostDetailsDialog(QDialog):
self.status_label.setText("")
# Populate favorites list
favourited_by = details.get('favourited_by', [])
favourited_by = details.get("favourited_by", [])
if favourited_by:
for account_data in favourited_by:
try:
@@ -179,7 +182,7 @@ class PostDetailsDialog(QDialog):
self.favorites_list.addItem(item)
# Populate boosts list
reblogged_by = details.get('reblogged_by', [])
reblogged_by = details.get("reblogged_by", [])
if reblogged_by:
for account_data in reblogged_by:
try:
@@ -199,8 +202,15 @@ class PostDetailsDialog(QDialog):
# Update tab titles with actual counts
actual_favorites = len(favourited_by)
actual_boosts = len(reblogged_by)
self.tabs.setTabText(0, f"Favorites ({actual_favorites})")
self.tabs.setTabText(1, f"Boosts ({actual_boosts})")
# Account for poll tab if it exists
# Tab order: Poll (if exists), Content, Favorites, Boosts
has_poll = hasattr(self.post, "poll") and self.post.poll
favorites_tab_index = 2 if has_poll else 1
boosts_tab_index = 3 if has_poll else 2
self.tabs.setTabText(favorites_tab_index, f"Favorites ({actual_favorites})")
self.tabs.setTabText(boosts_tab_index, f"Boosts ({actual_boosts})")
# Play success sound
self.sound_manager.play_success()
@@ -218,3 +228,211 @@ class PostDetailsDialog(QDialog):
# Play error sound
self.sound_manager.play_error()
def create_poll_widget(self):
"""Create poll voting widget for the poll tab"""
poll_widget = QWidget()
poll_layout = QVBoxLayout(poll_widget)
poll_data = self.post.poll
# Poll question (if exists)
if "question" in poll_data and poll_data["question"]:
question_label = QLabel(f"Question: {poll_data['question']}")
question_label.setAccessibleName("Poll Question")
question_label.setWordWrap(True)
poll_layout.addWidget(question_label)
# Check if user can still vote
can_vote = not poll_data.get("voted", False) and not poll_data.get(
"expired", False
)
if can_vote:
# Show interactive voting interface
self.poll_results_list = self.create_interactive_poll_widget(
poll_data, poll_layout
)
else:
# Show results as accessible list (like favorites/boosts)
self.poll_results_list = self.create_poll_results_list(poll_data)
poll_layout.addWidget(self.poll_results_list)
# Poll info
info_text = []
if "expires_at" in poll_data and poll_data["expires_at"]:
info_text.append(f"Expires: {poll_data['expires_at']}")
if "voters_count" in poll_data:
info_text.append(f"Total voters: {poll_data['voters_count']}")
if info_text:
info_label = QLabel(" | ".join(info_text))
info_label.setAccessibleName("Poll Information")
poll_layout.addWidget(info_label)
# Status message
if poll_data.get("voted", False):
voted_label = QLabel("✓ You have already voted in this poll")
voted_label.setAccessibleName("Vote Status")
poll_layout.addWidget(voted_label)
elif poll_data.get("expired", False):
expired_label = QLabel("This poll has expired")
expired_label.setAccessibleName("Poll Status")
poll_layout.addWidget(expired_label)
return poll_widget
def create_poll_results_list(self, poll_data):
"""Create accessible list widget for poll results (expired/voted polls)"""
results_list = QListWidget()
results_list.setAccessibleName("Poll Results")
# Add fake header for single-item navigation (like favorites/boosts)
fake_header = QListWidgetItem("Poll results:")
fake_header.setFlags(Qt.ItemIsEnabled) # Not selectable
results_list.addItem(fake_header)
# Add poll options with results
options = poll_data.get("options", [])
own_votes = poll_data.get("own_votes", [])
for i, option in enumerate(options):
vote_count = option.get("votes_count", 0)
option_title = option.get("title", f"Option {i + 1}")
# Mark user's votes
vote_indicator = "" if i in own_votes else ""
option_text = f"{option_title}: {vote_count} votes{vote_indicator}"
item = QListWidgetItem(option_text)
item.setData(Qt.UserRole, {"option_index": i, "option_data": option})
results_list.addItem(item)
return results_list
def create_interactive_poll_widget(self, poll_data, poll_layout):
"""Create interactive poll widget for active polls"""
# Poll options group
options_group = QGroupBox("Poll Options")
options_group.setAccessibleName("Poll Options")
options_layout = QVBoxLayout(options_group)
self.poll_option_widgets = []
self.poll_button_group = None
# Check if poll allows multiple choices
multiple_choice = poll_data.get("multiple", False)
if not multiple_choice:
# Single choice - use radio buttons
self.poll_button_group = QButtonGroup()
# Add options
options = poll_data.get("options", [])
for i, option in enumerate(options):
vote_count = option.get("votes_count", 0)
option_title = option.get("title", f"Option {i + 1}")
option_text = f"{option_title} ({vote_count} votes)"
if multiple_choice:
# Multiple choice - use checkboxes
option_widget = QCheckBox(option_text)
else:
# Single choice - use radio buttons
option_widget = QRadioButton(option_text)
self.poll_button_group.addButton(option_widget, i)
option_widget.setAccessibleName(f"Poll Option {i + 1}")
self.poll_option_widgets.append(option_widget)
options_layout.addWidget(option_widget)
poll_layout.addWidget(options_group)
# Vote button
vote_button = QPushButton("Submit Vote")
vote_button.setAccessibleName("Submit Poll Vote")
vote_button.clicked.connect(self.submit_poll_vote)
poll_layout.addWidget(vote_button)
return None # No list widget for interactive polls
def create_content_widget(self):
"""Create content widget for the content tab"""
content_widget = QWidget()
content_layout = QVBoxLayout(content_widget)
# Create comprehensive post content with all details in accessible text box
content_parts = []
# Author info
if hasattr(self.post, "account") and self.post.account:
username = getattr(self.post.account, "username", "unknown")
display_name = getattr(self.post.account, "display_name", "") or username
content_parts.append(f"Author: @{username} ({display_name})")
else:
content_parts.append("Author: Information not available")
content_parts.append("") # Empty line separator
# Post content
post_content = self.post.get_content_text()
if post_content.strip():
content_parts.append("Content:")
content_parts.append(post_content)
else:
content_parts.append("Content: (No text content)")
content_parts.append("") # Empty line separator
# Post metadata
metadata_parts = []
if hasattr(self.post, "created_at") and self.post.created_at:
metadata_parts.append(f"Posted: {self.post.created_at}")
if hasattr(self.post, "visibility") and self.post.visibility:
metadata_parts.append(f"Visibility: {self.post.visibility}")
if hasattr(self.post, "language") and self.post.language:
metadata_parts.append(f"Language: {self.post.language}")
if metadata_parts:
content_parts.append("Post Details:")
content_parts.extend(metadata_parts)
# Combine all parts into one accessible text widget
full_content = "\n".join(content_parts)
# Post content text (scrollable) with all details
content_text = QTextEdit()
content_text.setAccessibleName("Full Post Details")
content_text.setPlainText(full_content)
content_text.setReadOnly(True)
# Enable keyboard navigation in read-only text
content_text.setTextInteractionFlags(
Qt.TextSelectableByKeyboard | Qt.TextSelectableByMouse
)
content_layout.addWidget(content_text)
return content_widget
def submit_poll_vote(self):
"""Submit vote in poll"""
try:
selected_choices = []
for i, widget in enumerate(self.poll_option_widgets):
if widget.isChecked():
selected_choices.append(i)
if not selected_choices:
self.sound_manager.play_error()
return
# Emit vote signal for parent to handle
self.vote_submitted.emit(self.post, selected_choices)
# Close dialog after voting
self.accept()
except Exception as e:
self.logger.error(f"Error submitting poll vote: {e}")
self.sound_manager.play_error()
File diff suppressed because it is too large Load Diff