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:
@@ -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)
|
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
|
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. **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
|
### Required Patterns for Event-Heavy Operations
|
||||||
|
|
||||||
@@ -611,4 +612,82 @@ Examples of forbidden practices:
|
|||||||
- Shortened usernames or descriptions
|
- Shortened usernames or descriptions
|
||||||
- Abbreviated profile information
|
- 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.
|
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.
|
||||||
@@ -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
|
- **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
|
- **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
|
- **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
|
- **Thread Expansion**: Full conversation context fetching for complete thread viewing
|
||||||
- **Blocked/Muted Management**: Dedicated tabs for managing blocked and muted users
|
- **Blocked/Muted Management**: Dedicated tabs for managing blocked and muted users
|
||||||
- **Custom Emoji Support**: Instance-specific emoji support with caching
|
- **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+U**: Open URLs from selected post in browser
|
||||||
- **Ctrl+Shift+B**: Block user who authored selected post
|
- **Ctrl+Shift+B**: Block user who authored selected post
|
||||||
- **Ctrl+Shift+M**: Mute 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
|
### Navigation
|
||||||
- **Arrow Keys**: Navigate through posts
|
- **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
|
- **Vote Submission**: Submit votes with accessible button controls
|
||||||
|
|
||||||
### Viewing Results
|
### 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
|
- **Automatic Display**: Results shown immediately after voting or for expired polls
|
||||||
- **Navigable List**: Vote counts and percentages in an accessible list widget
|
- **Navigable List**: Vote counts and percentages in an accessible list widget
|
||||||
- **Arrow Key Navigation**: Review each option's results individually
|
- **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
|
- **Keyboard Only**: Complete functionality without mouse interaction
|
||||||
- **Clear Announcements**: Descriptive text for poll status and options
|
- **Clear Announcements**: Descriptive text for poll status and options
|
||||||
- **Focus Management**: Proper tab order and focus placement
|
- **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.)
|
- **Error Handling**: Accessible feedback for voting errors (duplicate votes, etc.)
|
||||||
|
|
||||||
## Accessibility Features
|
## Accessibility Features
|
||||||
@@ -283,6 +287,9 @@ Bifrost includes comprehensive poll support with full accessibility:
|
|||||||
- Accessible names and descriptions for all controls
|
- Accessible names and descriptions for all controls
|
||||||
- Thread expansion/collapse with audio feedback
|
- Thread expansion/collapse with audio feedback
|
||||||
- Poll creation and voting with full accessibility support
|
- 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
|
### Known Qt Display Quirk
|
||||||
|
|
||||||
|
|||||||
@@ -156,12 +156,20 @@ class ActivityPubClient:
|
|||||||
data['spoiler_text'] = content_warning
|
data['spoiler_text'] = content_warning
|
||||||
if in_reply_to_id:
|
if in_reply_to_id:
|
||||||
data['in_reply_to_id'] = 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:
|
if media_ids:
|
||||||
data['media_ids'] = media_ids
|
data['media_ids'] = media_ids
|
||||||
if poll:
|
if poll:
|
||||||
data['poll'] = poll
|
data['poll'] = poll
|
||||||
|
|
||||||
|
try:
|
||||||
return self._make_request('POST', '/api/v1/statuses', data=data)
|
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:
|
def delete_status(self, status_id: str) -> Dict:
|
||||||
"""Delete a status"""
|
"""Delete a status"""
|
||||||
|
|||||||
+341
-137
@@ -3,8 +3,15 @@ Main application window for Bifrost
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
|
QMainWindow,
|
||||||
QLabel, QMenuBar, QStatusBar, QPushButton, QTabWidget
|
QWidget,
|
||||||
|
QVBoxLayout,
|
||||||
|
QHBoxLayout,
|
||||||
|
QLabel,
|
||||||
|
QMenuBar,
|
||||||
|
QStatusBar,
|
||||||
|
QPushButton,
|
||||||
|
QTabWidget,
|
||||||
)
|
)
|
||||||
from PySide6.QtCore import Qt, Signal, QTimer
|
from PySide6.QtCore import Qt, Signal, QTimer
|
||||||
from PySide6.QtGui import QKeySequence, QAction, QTextCursor
|
from PySide6.QtGui import QKeySequence, QAction, QTextCursor
|
||||||
@@ -32,7 +39,7 @@ class MainWindow(QMainWindow):
|
|||||||
super().__init__()
|
super().__init__()
|
||||||
self.settings = SettingsManager()
|
self.settings = SettingsManager()
|
||||||
self.account_manager = AccountManager(self.settings)
|
self.account_manager = AccountManager(self.settings)
|
||||||
self.logger = logging.getLogger('bifrost.main')
|
self.logger = logging.getLogger("bifrost.main")
|
||||||
|
|
||||||
# Auto-refresh tracking
|
# Auto-refresh tracking
|
||||||
self.last_activity_time = time.time()
|
self.last_activity_time = time.time()
|
||||||
@@ -45,10 +52,12 @@ class MainWindow(QMainWindow):
|
|||||||
self.setup_ui()
|
self.setup_ui()
|
||||||
|
|
||||||
# Initialize centralized managers after timeline is created
|
# 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
|
# 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
|
# Error management - single point of truth for error handling
|
||||||
self.error_manager = ErrorManager(self, self.sound_coordinator)
|
self.error_manager = ErrorManager(self, self.sound_coordinator)
|
||||||
@@ -369,7 +378,7 @@ class MainWindow(QMainWindow):
|
|||||||
"""Mark that initial loading is complete"""
|
"""Mark that initial loading is complete"""
|
||||||
self.is_initial_load = False
|
self.is_initial_load = False
|
||||||
# Enable notifications on the timeline
|
# Enable notifications on the timeline
|
||||||
if hasattr(self.timeline, 'enable_notifications'):
|
if hasattr(self.timeline, "enable_notifications"):
|
||||||
self.timeline.enable_notifications()
|
self.timeline.enable_notifications()
|
||||||
|
|
||||||
def keyPressEvent(self, event):
|
def keyPressEvent(self, event):
|
||||||
@@ -383,7 +392,7 @@ class MainWindow(QMainWindow):
|
|||||||
self.update_refresh_mode()
|
self.update_refresh_mode()
|
||||||
|
|
||||||
# Skip if auto-refresh is disabled
|
# 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
|
return
|
||||||
|
|
||||||
# Skip if no account is active
|
# Skip if no account is active
|
||||||
@@ -395,7 +404,9 @@ class MainWindow(QMainWindow):
|
|||||||
return
|
return
|
||||||
|
|
||||||
# Get refresh interval from settings
|
# 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)
|
# Skip if streaming mode (interval = 0)
|
||||||
if refresh_interval == 0:
|
if refresh_interval == 0:
|
||||||
@@ -405,13 +416,17 @@ class MainWindow(QMainWindow):
|
|||||||
time_since_activity = time.time() - self.last_activity_time
|
time_since_activity = time.time() - self.last_activity_time
|
||||||
required_idle_time = refresh_interval + 10 # refresh_rate + 10 seconds
|
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:
|
if time_since_activity >= required_idle_time:
|
||||||
self.logger.debug("Auto-refresh condition met, triggering refresh")
|
self.logger.debug("Auto-refresh condition met, triggering refresh")
|
||||||
self.auto_refresh_timeline()
|
self.auto_refresh_timeline()
|
||||||
else:
|
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):
|
def auto_refresh_timeline(self):
|
||||||
"""Automatically refresh the timeline - DELEGATED TO TIMELINE"""
|
"""Automatically refresh the timeline - DELEGATED TO TIMELINE"""
|
||||||
@@ -437,12 +452,14 @@ class MainWindow(QMainWindow):
|
|||||||
def update_refresh_mode(self):
|
def update_refresh_mode(self):
|
||||||
"""Update refresh mode based on settings (0 = streaming, >0 = polling)"""
|
"""Update refresh mode based on settings (0 = streaming, >0 = polling)"""
|
||||||
# Prevent infinite recursion
|
# 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
|
return
|
||||||
self._updating_refresh_mode = True
|
self._updating_refresh_mode = True
|
||||||
|
|
||||||
try:
|
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
|
should_stream = refresh_interval == 0
|
||||||
|
|
||||||
# Check if server supports streaming
|
# Check if server supports streaming
|
||||||
@@ -450,20 +467,30 @@ class MainWindow(QMainWindow):
|
|||||||
active_account = self.account_manager.get_active_account()
|
active_account = self.account_manager.get_active_account()
|
||||||
if active_account:
|
if active_account:
|
||||||
# Disable streaming for known non-supporting servers
|
# 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:
|
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
|
should_stream = False
|
||||||
# Set a reasonable polling interval instead
|
# Set a reasonable polling interval instead
|
||||||
if refresh_interval == 0:
|
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
|
# Don't save this change to settings, just use it temporarily
|
||||||
refresh_interval = 120 # 2 minutes
|
refresh_interval = 120 # 2 minutes
|
||||||
|
|
||||||
# Only log refresh interval when it changes
|
# Only log refresh interval when it changes
|
||||||
if (refresh_interval != self._last_logged_refresh_interval or
|
if (
|
||||||
should_stream != self._last_logged_stream_mode):
|
refresh_interval != self._last_logged_refresh_interval
|
||||||
self.logger.debug(f"Refresh interval = {refresh_interval} seconds, should_stream = {should_stream}")
|
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_refresh_interval = refresh_interval
|
||||||
self._last_logged_stream_mode = should_stream
|
self._last_logged_stream_mode = should_stream
|
||||||
|
|
||||||
@@ -481,7 +508,7 @@ class MainWindow(QMainWindow):
|
|||||||
try:
|
try:
|
||||||
# Quick URL-based checks for known non-streaming servers
|
# Quick URL-based checks for known non-streaming servers
|
||||||
url_lower = instance_url.lower()
|
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")
|
self.logger.debug("GoToSocial detected in URL - no streaming support")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -489,7 +516,11 @@ class MainWindow(QMainWindow):
|
|||||||
active_account = self.account_manager.get_active_account()
|
active_account = self.account_manager.get_active_account()
|
||||||
if active_account:
|
if active_account:
|
||||||
client = self.account_manager.get_client_for_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")
|
self.logger.debug("Server previously failed streaming attempts")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -497,8 +528,8 @@ class MainWindow(QMainWindow):
|
|||||||
if client:
|
if client:
|
||||||
try:
|
try:
|
||||||
instance_info = client.get_instance_info()
|
instance_info = client.get_instance_info()
|
||||||
version = instance_info.get('version', '').lower()
|
version = instance_info.get("version", "").lower()
|
||||||
if 'gotosocial' in version:
|
if "gotosocial" in version:
|
||||||
self.logger.debug(f"GoToSocial detected via API: {version}")
|
self.logger.debug(f"GoToSocial detected via API: {version}")
|
||||||
return False
|
return False
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -519,24 +550,30 @@ class MainWindow(QMainWindow):
|
|||||||
if not active_account:
|
if not active_account:
|
||||||
self.logger.warning("No active account, cannot start streaming")
|
self.logger.warning("No active account, cannot start streaming")
|
||||||
return
|
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:
|
try:
|
||||||
# Stop any existing streaming
|
# Stop any existing streaming
|
||||||
self.stop_streaming_mode()
|
self.stop_streaming_mode()
|
||||||
|
|
||||||
# Create streaming client if needed
|
# Create streaming client if needed
|
||||||
if not self.streaming_client or self.streaming_client.instance_url != active_account.instance_url:
|
if (
|
||||||
self.streaming_client = self.account_manager.get_client_for_active_account()
|
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:
|
if not self.streaming_client:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Start streaming for current timeline type
|
# Start streaming for current timeline type
|
||||||
timeline_type = self.timeline.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(
|
self.streaming_client.start_streaming(
|
||||||
timeline_type,
|
timeline_type, callback=self.handle_streaming_event
|
||||||
callback=self.handle_streaming_event
|
|
||||||
)
|
)
|
||||||
self.streaming_mode = True
|
self.streaming_mode = True
|
||||||
self.logger.info(f"Started streaming for {timeline_type} timeline")
|
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):
|
def handle_streaming_event(self, event_type: str, data):
|
||||||
"""Handle real-time streaming events"""
|
"""Handle real-time streaming events"""
|
||||||
try:
|
try:
|
||||||
if event_type == 'new_post':
|
if event_type == "new_post":
|
||||||
# New post received via streaming
|
# New post received via streaming
|
||||||
self.add_streaming_post(data)
|
self.add_streaming_post(data)
|
||||||
elif event_type == 'new_notification':
|
elif event_type == "new_notification":
|
||||||
# New notification received
|
# New notification received
|
||||||
self.handle_streaming_notification(data)
|
self.handle_streaming_notification(data)
|
||||||
elif event_type == 'delete_post':
|
elif event_type == "delete_post":
|
||||||
# Post deleted
|
# Post deleted
|
||||||
self.remove_streaming_post(data)
|
self.remove_streaming_post(data)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -579,51 +616,64 @@ class MainWindow(QMainWindow):
|
|||||||
# Trigger a refresh to show new content
|
# Trigger a refresh to show new content
|
||||||
# In future, could add the post directly to avoid full refresh
|
# In future, could add the post directly to avoid full refresh
|
||||||
if not self.is_initial_load:
|
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
|
# Show notification of new content
|
||||||
timeline_name = {
|
timeline_name = {
|
||||||
'home': 'home timeline',
|
"home": "home timeline",
|
||||||
'local': 'local timeline',
|
"local": "local timeline",
|
||||||
'federated': 'federated timeline'
|
"federated": "federated timeline",
|
||||||
}.get(current_timeline, '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)
|
self.timeline.notification_manager.notify_new_content(timeline_name)
|
||||||
|
|
||||||
# Play sound for new content
|
# Play sound for new content
|
||||||
if hasattr(self.timeline, 'sound_manager'):
|
if hasattr(self.timeline, "sound_manager"):
|
||||||
self.timeline.sound_manager.play_timeline_update()
|
self.timeline.sound_manager.play_timeline_update()
|
||||||
|
|
||||||
# Try to add streaming post directly instead of full refresh
|
# Try to add streaming post directly instead of full refresh
|
||||||
self.logger.debug("Adding streaming post directly to timeline")
|
self.logger.debug("Adding streaming post directly to timeline")
|
||||||
try:
|
try:
|
||||||
from models.post import Post
|
from models.post import Post
|
||||||
|
|
||||||
streaming_post = Post.from_api_dict(post_data)
|
streaming_post = Post.from_api_dict(post_data)
|
||||||
self.timeline.add_streaming_post_to_timeline(streaming_post)
|
self.timeline.add_streaming_post_to_timeline(streaming_post)
|
||||||
except Exception as e:
|
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)
|
self.timeline.refresh(preserve_position=True)
|
||||||
|
|
||||||
def handle_streaming_notification(self, notification_data):
|
def handle_streaming_notification(self, notification_data):
|
||||||
"""Handle new notifications received via streaming"""
|
"""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 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.logger.debug("Refreshing notifications timeline for new notification")
|
||||||
self.timeline.refresh(preserve_position=True)
|
self.timeline.refresh(preserve_position=True)
|
||||||
|
|
||||||
# Play appropriate notification sound based on type
|
# Play appropriate notification sound based on type
|
||||||
if (hasattr(self.timeline, 'sound_manager') and not self.timeline.skip_notifications):
|
if (
|
||||||
notification_type = notification_data.get('type', 'notification')
|
hasattr(self.timeline, "sound_manager")
|
||||||
if notification_type == 'mention':
|
and not self.timeline.skip_notifications
|
||||||
|
):
|
||||||
|
notification_type = notification_data.get("type", "notification")
|
||||||
|
if notification_type == "mention":
|
||||||
self.timeline.sound_manager.play_mention()
|
self.timeline.sound_manager.play_mention()
|
||||||
elif notification_type == 'reblog':
|
elif notification_type == "reblog":
|
||||||
self.timeline.sound_manager.play_boost()
|
self.timeline.sound_manager.play_boost()
|
||||||
elif notification_type == 'favourite':
|
elif notification_type == "favourite":
|
||||||
self.timeline.sound_manager.play_favorite()
|
self.timeline.sound_manager.play_favorite()
|
||||||
elif notification_type == 'follow':
|
elif notification_type == "follow":
|
||||||
self.timeline.sound_manager.play_follow()
|
self.timeline.sound_manager.play_follow()
|
||||||
else:
|
else:
|
||||||
self.timeline.sound_manager.play_notification()
|
self.timeline.sound_manager.play_notification()
|
||||||
@@ -645,19 +695,18 @@ class MainWindow(QMainWindow):
|
|||||||
|
|
||||||
# Use centralized PostManager instead of duplicate logic
|
# Use centralized PostManager instead of duplicate logic
|
||||||
success = self.post_manager.create_post(
|
success = self.post_manager.create_post(
|
||||||
content=post_data.get('content', ''),
|
content=post_data.get("content", ""),
|
||||||
visibility=post_data.get('visibility', 'public'),
|
visibility=post_data.get("visibility", "public"),
|
||||||
content_type=post_data.get('content_type', 'text/plain'),
|
content_type=post_data.get("content_type", "text/plain"),
|
||||||
content_warning=post_data.get('content_warning'),
|
content_warning=post_data.get("content_warning"),
|
||||||
in_reply_to_id=post_data.get('in_reply_to_id'),
|
in_reply_to_id=post_data.get("in_reply_to_id"),
|
||||||
poll=post_data.get('poll'),
|
poll=post_data.get("poll"),
|
||||||
media_ids=post_data.get('media_ids')
|
media_ids=post_data.get("media_ids"),
|
||||||
)
|
)
|
||||||
|
|
||||||
if not success:
|
if not success:
|
||||||
self.error_manager.handle_validation_error(
|
self.error_manager.handle_validation_error(
|
||||||
"Failed to start post submission",
|
"Failed to start post submission", context="post_creation"
|
||||||
context="post_creation"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def on_post_success(self, result_data):
|
def on_post_success(self, result_data):
|
||||||
@@ -666,7 +715,7 @@ class MainWindow(QMainWindow):
|
|||||||
self.error_manager.show_success_message(
|
self.error_manager.show_success_message(
|
||||||
"Post sent successfully!",
|
"Post sent successfully!",
|
||||||
context="post_creation",
|
context="post_creation",
|
||||||
play_sound=False # PostManager already plays sound
|
play_sound=False, # PostManager already plays sound
|
||||||
)
|
)
|
||||||
|
|
||||||
# Refresh timeline to show the new post
|
# Refresh timeline to show the new post
|
||||||
@@ -676,8 +725,7 @@ class MainWindow(QMainWindow):
|
|||||||
"""Handle failed post submission - CENTRALIZED VIA POSTMANAGER"""
|
"""Handle failed post submission - CENTRALIZED VIA POSTMANAGER"""
|
||||||
# Note: Error sound is handled by PostManager to avoid duplication
|
# Note: Error sound is handled by PostManager to avoid duplication
|
||||||
self.error_manager.handle_api_error(
|
self.error_manager.handle_api_error(
|
||||||
f"Post failed: {error_message}",
|
f"Post failed: {error_message}", context="post_creation"
|
||||||
context="post_creation"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def show_settings(self):
|
def show_settings(self):
|
||||||
@@ -689,7 +737,7 @@ class MainWindow(QMainWindow):
|
|||||||
def on_settings_changed(self):
|
def on_settings_changed(self):
|
||||||
"""Handle settings changes"""
|
"""Handle settings changes"""
|
||||||
# Reload sound manager with new settings
|
# Reload sound manager with new settings
|
||||||
if hasattr(self.timeline, 'sound_manager'):
|
if hasattr(self.timeline, "sound_manager"):
|
||||||
self.timeline.sound_manager.reload_settings()
|
self.timeline.sound_manager.reload_settings()
|
||||||
|
|
||||||
# Check if refresh mode changed
|
# Check if refresh mode changed
|
||||||
@@ -713,15 +761,40 @@ class MainWindow(QMainWindow):
|
|||||||
|
|
||||||
def switch_timeline(self, index, from_tab_change=False):
|
def switch_timeline(self, index, from_tab_change=False):
|
||||||
"""Switch to timeline by index with loading feedback"""
|
"""Switch to timeline by index with loading feedback"""
|
||||||
timeline_names = ["Home", "Messages", "Notifications", "Local", "Federated", "Bookmarks", "Followers", "Following", "Blocked", "Muted"]
|
timeline_names = [
|
||||||
timeline_types = ["home", "conversations", "notifications", "local", "federated", "bookmarks", "followers", "following", "blocked", "muted"]
|
"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):
|
if 0 <= index < len(timeline_names):
|
||||||
timeline_name = timeline_names[index]
|
timeline_name = timeline_names[index]
|
||||||
timeline_type = timeline_types[index]
|
timeline_type = timeline_types[index]
|
||||||
|
|
||||||
# Prevent duplicate calls for the same timeline
|
# 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
|
return
|
||||||
self._current_timeline_switching = True
|
self._current_timeline_switching = True
|
||||||
|
|
||||||
@@ -747,13 +820,18 @@ class MainWindow(QMainWindow):
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Error feedback
|
# Error feedback
|
||||||
if hasattr(self.timeline, 'sound_manager'):
|
if hasattr(self.timeline, "sound_manager"):
|
||||||
self.timeline.sound_manager.play_error()
|
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:
|
finally:
|
||||||
# Reset the flag after a brief delay to allow the operation to complete
|
# Reset the flag after a brief delay to allow the operation to complete
|
||||||
from PySide6.QtCore import QTimer
|
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):
|
def get_selected_post(self):
|
||||||
"""Get the currently selected post from timeline"""
|
"""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"
|
"Welcome to Bifrost! You need to add a fediverse account to get started.\n\n"
|
||||||
"Would you like to add an account now?",
|
"Would you like to add an account now?",
|
||||||
QMessageBox.Yes | QMessageBox.No,
|
QMessageBox.Yes | QMessageBox.No,
|
||||||
QMessageBox.Yes
|
QMessageBox.Yes,
|
||||||
)
|
)
|
||||||
|
|
||||||
if result == QMessageBox.Yes:
|
if result == QMessageBox.Yes:
|
||||||
@@ -837,22 +915,106 @@ class MainWindow(QMainWindow):
|
|||||||
account = self.account_manager.get_account_by_id(account_id)
|
account = self.account_manager.get_account_by_id(account_id)
|
||||||
if account:
|
if account:
|
||||||
self.update_status_label()
|
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
|
# Refresh timeline with new account
|
||||||
self.timeline.request_post_action_refresh("account_action")
|
self.timeline.request_post_action_refresh("account_action")
|
||||||
|
|
||||||
def reply_to_post(self, post):
|
def reply_to_post(self, post):
|
||||||
"""Reply to a specific post"""
|
"""Reply to a specific post or conversation"""
|
||||||
dialog = ComposeDialog(self.account_manager, self)
|
dialog = ComposeDialog(self.account_manager, self)
|
||||||
# Pre-fill with reply mention using full fediverse handle
|
# Use the new setup_reply method to handle visibility and text
|
||||||
dialog.text_edit.setPlainText(f"@{post.account.acct} ")
|
dialog.setup_reply(post)
|
||||||
# Move cursor to end
|
|
||||||
cursor = dialog.text_edit.textCursor()
|
# Handle different types of replies
|
||||||
cursor.movePosition(QTextCursor.MoveOperation.End)
|
if hasattr(post, "conversation") and post.conversation:
|
||||||
dialog.text_edit.setTextCursor(cursor)
|
# This is a conversation - send as direct message to participants
|
||||||
dialog.post_sent.connect(lambda data: self.on_post_sent({**data, 'in_reply_to_id': post.id}))
|
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()
|
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):
|
def boost_post(self, post):
|
||||||
"""Boost/unboost a post"""
|
"""Boost/unboost a post"""
|
||||||
active_account = self.account_manager.get_active_account()
|
active_account = self.account_manager.get_active_account()
|
||||||
@@ -870,7 +1032,7 @@ class MainWindow(QMainWindow):
|
|||||||
client.reblog_status(post.id)
|
client.reblog_status(post.id)
|
||||||
self.status_bar.showMessage("Post boosted", 2000)
|
self.status_bar.showMessage("Post boosted", 2000)
|
||||||
# Play boost sound for successful boost
|
# 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.logger.debug("Playing boost sound for user boost action")
|
||||||
self.timeline.sound_manager.play_boost()
|
self.timeline.sound_manager.play_boost()
|
||||||
# Refresh timeline to show updated state
|
# Refresh timeline to show updated state
|
||||||
@@ -895,7 +1057,7 @@ class MainWindow(QMainWindow):
|
|||||||
client.favourite_status(post.id)
|
client.favourite_status(post.id)
|
||||||
self.status_bar.showMessage("Post favorited", 2000)
|
self.status_bar.showMessage("Post favorited", 2000)
|
||||||
# Play favorite sound for successful favorite
|
# 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.logger.debug("Playing favorite sound for user favorite action")
|
||||||
self.timeline.sound_manager.play_favorite()
|
self.timeline.sound_manager.play_favorite()
|
||||||
# Refresh timeline to show updated state
|
# Refresh timeline to show updated state
|
||||||
@@ -912,26 +1074,28 @@ class MainWindow(QMainWindow):
|
|||||||
# Create User object from Account data
|
# Create User object from Account data
|
||||||
account = post.account
|
account = post.account
|
||||||
account_data = {
|
account_data = {
|
||||||
'id': account.id,
|
"id": account.id,
|
||||||
'username': account.username,
|
"username": account.username,
|
||||||
'acct': account.acct,
|
"acct": account.acct,
|
||||||
'display_name': account.display_name,
|
"display_name": account.display_name,
|
||||||
'note': account.note,
|
"note": account.note,
|
||||||
'url': account.url,
|
"url": account.url,
|
||||||
'avatar': account.avatar,
|
"avatar": account.avatar,
|
||||||
'avatar_static': account.avatar_static,
|
"avatar_static": account.avatar_static,
|
||||||
'header': account.header,
|
"header": account.header,
|
||||||
'header_static': account.header_static,
|
"header_static": account.header_static,
|
||||||
'locked': account.locked,
|
"locked": account.locked,
|
||||||
'bot': account.bot,
|
"bot": account.bot,
|
||||||
'discoverable': account.discoverable,
|
"discoverable": account.discoverable,
|
||||||
'group': account.group,
|
"group": account.group,
|
||||||
'created_at': account.created_at.isoformat() if account.created_at else None,
|
"created_at": (
|
||||||
'followers_count': account.followers_count,
|
account.created_at.isoformat() if account.created_at else None
|
||||||
'following_count': account.following_count,
|
),
|
||||||
'statuses_count': account.statuses_count,
|
"followers_count": account.followers_count,
|
||||||
'fields': [], # Will be loaded from API
|
"following_count": account.following_count,
|
||||||
'emojis': [] # Will be loaded from API
|
"statuses_count": account.statuses_count,
|
||||||
|
"fields": [], # Will be loaded from API
|
||||||
|
"emojis": [], # Will be loaded from API
|
||||||
}
|
}
|
||||||
|
|
||||||
user = User.from_api_dict(account_data)
|
user = User.from_api_dict(account_data)
|
||||||
@@ -941,29 +1105,34 @@ class MainWindow(QMainWindow):
|
|||||||
account_manager=self.account_manager,
|
account_manager=self.account_manager,
|
||||||
sound_manager=self.timeline.sound_manager,
|
sound_manager=self.timeline.sound_manager,
|
||||||
initial_user=user,
|
initial_user=user,
|
||||||
parent=self
|
parent=self,
|
||||||
)
|
)
|
||||||
dialog.exec()
|
dialog.exec()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.status_bar.showMessage(f"Error opening profile: {str(e)}", 3000)
|
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()
|
self.timeline.sound_manager.play_error()
|
||||||
|
|
||||||
def update_status_label(self):
|
def update_status_label(self):
|
||||||
"""Update the status label with current account info"""
|
"""Update the status label with current account info"""
|
||||||
active_account = self.account_manager.get_active_account()
|
active_account = self.account_manager.get_active_account()
|
||||||
if 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:
|
else:
|
||||||
self.status_label.setText("No account connected")
|
self.status_label.setText("No account connected")
|
||||||
|
|
||||||
def quit_application(self):
|
def quit_application(self):
|
||||||
"""Quit the application with shutdown sound"""
|
"""Quit the application with shutdown sound"""
|
||||||
self._shutdown_sound_played = True # Mark that we're handling the shutdown sound
|
self._shutdown_sound_played = (
|
||||||
if hasattr(self.timeline, 'sound_manager'):
|
True # Mark that we're handling the shutdown sound
|
||||||
|
)
|
||||||
|
if hasattr(self.timeline, "sound_manager"):
|
||||||
self.timeline.sound_manager.play_shutdown()
|
self.timeline.sound_manager.play_shutdown()
|
||||||
# Wait briefly for sound to start playing
|
# Wait briefly for sound to start playing
|
||||||
from PySide6.QtCore import QTimer
|
from PySide6.QtCore import QTimer
|
||||||
|
|
||||||
QTimer.singleShot(500, self.close)
|
QTimer.singleShot(500, self.close)
|
||||||
else:
|
else:
|
||||||
self.close()
|
self.close()
|
||||||
@@ -1006,12 +1175,17 @@ class MainWindow(QMainWindow):
|
|||||||
|
|
||||||
# Check if this is user's own post
|
# Check if this is user's own post
|
||||||
active_account = self.account_manager.get_active_account()
|
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)
|
self.status_bar.showMessage("Cannot delete: No active account", 2000)
|
||||||
return
|
return
|
||||||
|
|
||||||
is_own_post = (post.account.username == active_account.username and
|
is_own_post = (
|
||||||
post.account.acct.split('@')[-1] == active_account.instance_url.replace('https://', '').replace('http://', ''))
|
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:
|
if not is_own_post:
|
||||||
self.status_bar.showMessage("Cannot delete: Not your post", 2000)
|
self.status_bar.showMessage("Cannot delete: Not your post", 2000)
|
||||||
@@ -1023,9 +1197,9 @@ class MainWindow(QMainWindow):
|
|||||||
result = QMessageBox.question(
|
result = QMessageBox.question(
|
||||||
self,
|
self,
|
||||||
"Delete Post",
|
"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.Yes | QMessageBox.No,
|
||||||
QMessageBox.No
|
QMessageBox.No,
|
||||||
)
|
)
|
||||||
|
|
||||||
if result == QMessageBox.Yes:
|
if result == QMessageBox.Yes:
|
||||||
@@ -1044,12 +1218,17 @@ class MainWindow(QMainWindow):
|
|||||||
"""Edit a post"""
|
"""Edit a post"""
|
||||||
# Check if this is user's own post
|
# Check if this is user's own post
|
||||||
active_account = self.account_manager.get_active_account()
|
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)
|
self.status_bar.showMessage("Cannot edit: No active account", 2000)
|
||||||
return
|
return
|
||||||
|
|
||||||
is_own_post = (post.account.username == active_account.username and
|
is_own_post = (
|
||||||
post.account.acct.split('@')[-1] == active_account.instance_url.replace('https://', '').replace('http://', ''))
|
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:
|
if not is_own_post:
|
||||||
self.status_bar.showMessage("Cannot edit: Not your post", 2000)
|
self.status_bar.showMessage("Cannot edit: Not your post", 2000)
|
||||||
@@ -1070,10 +1249,10 @@ class MainWindow(QMainWindow):
|
|||||||
return
|
return
|
||||||
client.edit_status(
|
client.edit_status(
|
||||||
post.id,
|
post.id,
|
||||||
content=data['content'],
|
content=data["content"],
|
||||||
visibility=data['visibility'],
|
visibility=data["visibility"],
|
||||||
content_type=data.get('content_type', 'text/plain'),
|
content_type=data.get("content_type", "text/plain"),
|
||||||
content_warning=data['content_warning']
|
content_warning=data["content_warning"],
|
||||||
)
|
)
|
||||||
self.status_bar.showMessage("Post edited successfully", 2000)
|
self.status_bar.showMessage("Post edited successfully", 2000)
|
||||||
# Refresh timeline to show edited post
|
# Refresh timeline to show edited post
|
||||||
@@ -1087,7 +1266,7 @@ class MainWindow(QMainWindow):
|
|||||||
def follow_user(self, post):
|
def follow_user(self, post):
|
||||||
"""Follow a user"""
|
"""Follow a user"""
|
||||||
active_account = self.account_manager.get_active_account()
|
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)
|
self.status_bar.showMessage("Cannot follow: No active account", 2000)
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -1099,7 +1278,7 @@ class MainWindow(QMainWindow):
|
|||||||
username = post.account.display_name or post.account.username
|
username = post.account.display_name or post.account.username
|
||||||
self.status_bar.showMessage(f"Followed {username}", 2000)
|
self.status_bar.showMessage(f"Followed {username}", 2000)
|
||||||
# Play follow sound for successful follow
|
# Play follow sound for successful follow
|
||||||
if hasattr(self.timeline, 'sound_manager'):
|
if hasattr(self.timeline, "sound_manager"):
|
||||||
self.timeline.sound_manager.play_follow()
|
self.timeline.sound_manager.play_follow()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.status_bar.showMessage(f"Follow failed: {str(e)}", 3000)
|
self.status_bar.showMessage(f"Follow failed: {str(e)}", 3000)
|
||||||
@@ -1107,7 +1286,7 @@ class MainWindow(QMainWindow):
|
|||||||
def unfollow_user(self, post):
|
def unfollow_user(self, post):
|
||||||
"""Unfollow a user"""
|
"""Unfollow a user"""
|
||||||
active_account = self.account_manager.get_active_account()
|
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)
|
self.status_bar.showMessage("Cannot unfollow: No active account", 2000)
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -1119,14 +1298,21 @@ class MainWindow(QMainWindow):
|
|||||||
username = post.account.display_name or post.account.username
|
username = post.account.display_name or post.account.username
|
||||||
self.status_bar.showMessage(f"Unfollowed {username}", 2000)
|
self.status_bar.showMessage(f"Unfollowed {username}", 2000)
|
||||||
# Play unfollow sound for successful unfollow
|
# Play unfollow sound for successful unfollow
|
||||||
if hasattr(self.timeline, 'sound_manager'):
|
if hasattr(self.timeline, "sound_manager"):
|
||||||
self.timeline.sound_manager.play_unfollow()
|
self.timeline.sound_manager.play_unfollow()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.status_bar.showMessage(f"Unfollow failed: {str(e)}", 3000)
|
self.status_bar.showMessage(f"Unfollow failed: {str(e)}", 3000)
|
||||||
|
|
||||||
def show_manual_follow_dialog(self):
|
def show_manual_follow_dialog(self):
|
||||||
"""Show dialog to manually follow a user by @username@instance"""
|
"""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 = QDialog(self)
|
||||||
dialog.setWindowTitle("Follow User")
|
dialog.setWindowTitle("Follow User")
|
||||||
@@ -1176,7 +1362,7 @@ class MainWindow(QMainWindow):
|
|||||||
return
|
return
|
||||||
|
|
||||||
# Remove @ prefix if present
|
# Remove @ prefix if present
|
||||||
if username.startswith('@'):
|
if username.startswith("@"):
|
||||||
username = username[1:]
|
username = username[1:]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -1193,7 +1379,10 @@ class MainWindow(QMainWindow):
|
|||||||
# Find exact match
|
# Find exact match
|
||||||
target_account = None
|
target_account = None
|
||||||
for account in accounts:
|
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
|
target_account = account
|
||||||
break
|
break
|
||||||
|
|
||||||
@@ -1202,11 +1391,13 @@ class MainWindow(QMainWindow):
|
|||||||
return
|
return
|
||||||
|
|
||||||
# Follow the account
|
# Follow the account
|
||||||
client.follow_account(target_account['id'])
|
client.follow_account(target_account["id"])
|
||||||
display_name = target_account.get('display_name') or target_account['username']
|
display_name = (
|
||||||
|
target_account.get("display_name") or target_account["username"]
|
||||||
|
)
|
||||||
self.status_bar.showMessage(f"Followed {display_name}", 2000)
|
self.status_bar.showMessage(f"Followed {display_name}", 2000)
|
||||||
# Play follow sound for successful follow
|
# Play follow sound for successful follow
|
||||||
if hasattr(self.timeline, 'sound_manager'):
|
if hasattr(self.timeline, "sound_manager"):
|
||||||
self.timeline.sound_manager.play_follow()
|
self.timeline.sound_manager.play_follow()
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -1231,13 +1422,18 @@ class MainWindow(QMainWindow):
|
|||||||
def block_user(self, post):
|
def block_user(self, post):
|
||||||
"""Block a user with confirmation dialog"""
|
"""Block a user with confirmation dialog"""
|
||||||
active_account = self.account_manager.get_active_account()
|
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)
|
self.status_bar.showMessage("Cannot block: No active account", 2000)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Don't allow blocking yourself
|
# Don't allow blocking yourself
|
||||||
is_own_post = (post.account.username == active_account.username and
|
is_own_post = (
|
||||||
post.account.acct.split('@')[-1] == active_account.instance_url.replace('https://', '').replace('http://', ''))
|
post.account.username == active_account.username
|
||||||
|
and post.account.acct.split("@")[-1]
|
||||||
|
== active_account.instance_url.replace("https://", "").replace(
|
||||||
|
"http://", ""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
if is_own_post:
|
if is_own_post:
|
||||||
self.status_bar.showMessage("Cannot block: Cannot block yourself", 2000)
|
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"
|
f"Are you sure you want to block {username} ({full_username})?\n\n"
|
||||||
"This will prevent them from following you and seeing your posts.",
|
"This will prevent them from following you and seeing your posts.",
|
||||||
QMessageBox.Yes | QMessageBox.No,
|
QMessageBox.Yes | QMessageBox.No,
|
||||||
QMessageBox.No
|
QMessageBox.No,
|
||||||
)
|
)
|
||||||
|
|
||||||
if result == QMessageBox.Yes:
|
if result == QMessageBox.Yes:
|
||||||
@@ -1264,23 +1460,28 @@ class MainWindow(QMainWindow):
|
|||||||
client.block_account(post.account.id)
|
client.block_account(post.account.id)
|
||||||
self.status_bar.showMessage(f"Blocked {username}", 2000)
|
self.status_bar.showMessage(f"Blocked {username}", 2000)
|
||||||
# Play success sound for successful block
|
# Play success sound for successful block
|
||||||
if hasattr(self.timeline, 'sound_manager'):
|
if hasattr(self.timeline, "sound_manager"):
|
||||||
self.timeline.sound_manager.play_success()
|
self.timeline.sound_manager.play_success()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.status_bar.showMessage(f"Block failed: {str(e)}", 3000)
|
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()
|
self.timeline.sound_manager.play_error()
|
||||||
|
|
||||||
def mute_user(self, post):
|
def mute_user(self, post):
|
||||||
"""Mute a user"""
|
"""Mute a user"""
|
||||||
active_account = self.account_manager.get_active_account()
|
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)
|
self.status_bar.showMessage("Cannot mute: No active account", 2000)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Don't allow muting yourself
|
# Don't allow muting yourself
|
||||||
is_own_post = (post.account.username == active_account.username and
|
is_own_post = (
|
||||||
post.account.acct.split('@')[-1] == active_account.instance_url.replace('https://', '').replace('http://', ''))
|
post.account.username == active_account.username
|
||||||
|
and post.account.acct.split("@")[-1]
|
||||||
|
== active_account.instance_url.replace("https://", "").replace(
|
||||||
|
"http://", ""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
if is_own_post:
|
if is_own_post:
|
||||||
self.status_bar.showMessage("Cannot mute: Cannot mute yourself", 2000)
|
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
|
username = post.account.display_name or post.account.username
|
||||||
self.status_bar.showMessage(f"Muted {username}", 2000)
|
self.status_bar.showMessage(f"Muted {username}", 2000)
|
||||||
# Play success sound for successful mute
|
# Play success sound for successful mute
|
||||||
if hasattr(self.timeline, 'sound_manager'):
|
if hasattr(self.timeline, "sound_manager"):
|
||||||
self.timeline.sound_manager.play_success()
|
self.timeline.sound_manager.play_success()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.status_bar.showMessage(f"Mute failed: {str(e)}", 3000)
|
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()
|
self.timeline.sound_manager.play_error()
|
||||||
|
|
||||||
def closeEvent(self, event):
|
def closeEvent(self, event):
|
||||||
@@ -1307,10 +1508,13 @@ class MainWindow(QMainWindow):
|
|||||||
self.stop_streaming_mode()
|
self.stop_streaming_mode()
|
||||||
|
|
||||||
# Only play shutdown sound if not already played through quit_application
|
# 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()
|
self.timeline.sound_manager.play_shutdown()
|
||||||
# Wait briefly for sound to complete
|
# Wait briefly for sound to complete
|
||||||
from PySide6.QtCore import QTimer, QEventLoop
|
from PySide6.QtCore import QTimer, QEventLoop
|
||||||
|
|
||||||
loop = QEventLoop()
|
loop = QEventLoop()
|
||||||
QTimer.singleShot(500, loop.quit)
|
QTimer.singleShot(500, loop.quit)
|
||||||
loop.exec()
|
loop.exec()
|
||||||
|
|||||||
@@ -44,7 +44,15 @@ class PostThread(QThread):
|
|||||||
self.post_success.emit(result)
|
self.post_success.emit(result)
|
||||||
|
|
||||||
except Exception as e:
|
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):
|
class PostManager(QObject):
|
||||||
|
|||||||
+148
-48
@@ -3,9 +3,19 @@ Compose post dialog for creating new posts
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QDialog, QVBoxLayout, QHBoxLayout, QTextEdit,
|
QDialog,
|
||||||
QPushButton, QLabel, QDialogButtonBox, QCheckBox,
|
QVBoxLayout,
|
||||||
QComboBox, QGroupBox, QLineEdit, QSpinBox, QMessageBox
|
QHBoxLayout,
|
||||||
|
QTextEdit,
|
||||||
|
QPushButton,
|
||||||
|
QLabel,
|
||||||
|
QDialogButtonBox,
|
||||||
|
QCheckBox,
|
||||||
|
QComboBox,
|
||||||
|
QGroupBox,
|
||||||
|
QLineEdit,
|
||||||
|
QSpinBox,
|
||||||
|
QMessageBox,
|
||||||
)
|
)
|
||||||
from PySide6.QtCore import Qt, Signal, QThread
|
from PySide6.QtCore import Qt, Signal, QThread
|
||||||
from PySide6.QtGui import QKeySequence, QShortcut
|
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
|
# NOTE: PostThread removed - now using centralized PostManager in main_window.py
|
||||||
# This eliminates duplicate posting logic and centralizes all post operations
|
# This eliminates duplicate posting logic and centralizes all post operations
|
||||||
|
|
||||||
|
|
||||||
class ComposeDialog(QDialog):
|
class ComposeDialog(QDialog):
|
||||||
"""Dialog for composing new posts"""
|
"""Dialog for composing new posts"""
|
||||||
|
|
||||||
@@ -33,7 +44,7 @@ class ComposeDialog(QDialog):
|
|||||||
self.sound_manager = SoundManager(self.settings)
|
self.sound_manager = SoundManager(self.settings)
|
||||||
self.account_manager = account_manager
|
self.account_manager = account_manager
|
||||||
self.media_upload_widget = None
|
self.media_upload_widget = None
|
||||||
self.logger = logging.getLogger('bifrost.compose')
|
self.logger = logging.getLogger("bifrost.compose")
|
||||||
self.setup_ui()
|
self.setup_ui()
|
||||||
self.setup_shortcuts()
|
self.setup_shortcuts()
|
||||||
self.load_default_settings()
|
self.load_default_settings()
|
||||||
@@ -54,8 +65,12 @@ class ComposeDialog(QDialog):
|
|||||||
# Main text area with autocomplete
|
# Main text area with autocomplete
|
||||||
self.text_edit = AutocompleteTextEdit(sound_manager=self.sound_manager)
|
self.text_edit = AutocompleteTextEdit(sound_manager=self.sound_manager)
|
||||||
self.text_edit.setAccessibleName("Post Content")
|
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.setAccessibleDescription(
|
||||||
self.text_edit.setPlaceholderText("What's on your mind? Type @ for mentions, : for emojis")
|
"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.setTabChangesFocus(True) # Allow Tab to exit the text area
|
||||||
self.text_edit.textChanged.connect(self.update_char_count)
|
self.text_edit.textChanged.connect(self.update_char_count)
|
||||||
self.text_edit.mention_requested.connect(self.load_mention_suggestions)
|
self.text_edit.mention_requested.connect(self.load_mention_suggestions)
|
||||||
@@ -73,18 +88,17 @@ class ComposeDialog(QDialog):
|
|||||||
settings_row1.addWidget(QLabel("Visibility:"))
|
settings_row1.addWidget(QLabel("Visibility:"))
|
||||||
self.visibility_combo = AccessibleComboBox()
|
self.visibility_combo = AccessibleComboBox()
|
||||||
self.visibility_combo.setAccessibleName("Post Visibility")
|
self.visibility_combo.setAccessibleName("Post Visibility")
|
||||||
self.visibility_combo.addItems([
|
self.visibility_combo.addItems(
|
||||||
"Public",
|
["Public", "Unlisted", "Followers Only", "Direct Message"]
|
||||||
"Unlisted",
|
)
|
||||||
"Followers Only",
|
|
||||||
"Direct Message"
|
|
||||||
])
|
|
||||||
settings_row1.addWidget(self.visibility_combo)
|
settings_row1.addWidget(self.visibility_combo)
|
||||||
|
|
||||||
settings_row1.addWidget(QLabel("Content Type:"))
|
settings_row1.addWidget(QLabel("Content Type:"))
|
||||||
self.content_type_combo = AccessibleComboBox()
|
self.content_type_combo = AccessibleComboBox()
|
||||||
self.content_type_combo.setAccessibleName("Content Type")
|
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("Plain Text", "text/plain")
|
||||||
self.content_type_combo.addItem("Markdown", "text/markdown")
|
self.content_type_combo.addItem("Markdown", "text/markdown")
|
||||||
self.content_type_combo.addItem("HTML", "text/html")
|
self.content_type_combo.addItem("HTML", "text/html")
|
||||||
@@ -101,10 +115,14 @@ class ComposeDialog(QDialog):
|
|||||||
|
|
||||||
self.cw_edit = QTextEdit()
|
self.cw_edit = QTextEdit()
|
||||||
self.cw_edit.setAccessibleName("Content Warning Text")
|
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.setPlaceholderText("Describe what this post contains...")
|
||||||
self.cw_edit.setMaximumHeight(60)
|
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()
|
self.cw_edit.hide()
|
||||||
options_layout.addWidget(self.cw_edit)
|
options_layout.addWidget(self.cw_edit)
|
||||||
|
|
||||||
@@ -127,7 +145,9 @@ class ComposeDialog(QDialog):
|
|||||||
|
|
||||||
option_edit = QLineEdit()
|
option_edit = QLineEdit()
|
||||||
option_edit.setAccessibleName(f"Poll Option {i+1}")
|
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}...")
|
option_edit.setPlaceholderText(f"Poll option {i+1}...")
|
||||||
if i >= 2: # First two options are required, others optional
|
if i >= 2: # First two options are required, others optional
|
||||||
option_edit.setPlaceholderText(f"Poll option {i+1} (optional)...")
|
option_edit.setPlaceholderText(f"Poll option {i+1} (optional)...")
|
||||||
@@ -143,7 +163,9 @@ class ComposeDialog(QDialog):
|
|||||||
poll_settings_layout.addWidget(QLabel("Duration:"))
|
poll_settings_layout.addWidget(QLabel("Duration:"))
|
||||||
self.poll_duration = QSpinBox()
|
self.poll_duration = QSpinBox()
|
||||||
self.poll_duration.setAccessibleName("Poll Duration in Hours")
|
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.setMinimum(1)
|
||||||
self.poll_duration.setMaximum(24 * 7) # 1 week max
|
self.poll_duration.setMaximum(24 * 7) # 1 week max
|
||||||
self.poll_duration.setValue(24) # Default 24 hours
|
self.poll_duration.setValue(24) # Default 24 hours
|
||||||
@@ -174,7 +196,9 @@ class ComposeDialog(QDialog):
|
|||||||
self.media_upload_widget = None
|
self.media_upload_widget = None
|
||||||
media_placeholder = QLabel("Please log in to upload media")
|
media_placeholder = QLabel("Please log in to upload media")
|
||||||
media_placeholder.setAccessibleName("Media Upload Status")
|
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)
|
layout.addWidget(media_placeholder)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error(f"Failed to create media upload widget: {e}")
|
self.logger.error(f"Failed to create media upload widget: {e}")
|
||||||
@@ -182,7 +206,9 @@ class ComposeDialog(QDialog):
|
|||||||
# Add error placeholder
|
# Add error placeholder
|
||||||
error_placeholder = QLabel("Media upload temporarily unavailable")
|
error_placeholder = QLabel("Media upload temporarily unavailable")
|
||||||
error_placeholder.setAccessibleName("Media Upload Error")
|
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)
|
layout.addWidget(error_placeholder)
|
||||||
|
|
||||||
# Button box
|
# Button box
|
||||||
@@ -219,11 +245,62 @@ class ComposeDialog(QDialog):
|
|||||||
def load_default_settings(self):
|
def load_default_settings(self):
|
||||||
"""Load default settings from configuration"""
|
"""Load default settings from configuration"""
|
||||||
# Set default content type
|
# 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)
|
index = self.content_type_combo.findData(default_type)
|
||||||
if index >= 0:
|
if index >= 0:
|
||||||
self.content_type_combo.setCurrentIndex(index)
|
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):
|
def toggle_content_warning(self, enabled: bool):
|
||||||
"""Toggle content warning field visibility"""
|
"""Toggle content warning field visibility"""
|
||||||
if enabled:
|
if enabled:
|
||||||
@@ -260,7 +337,9 @@ class ComposeDialog(QDialog):
|
|||||||
if char_count > 500:
|
if char_count > 500:
|
||||||
self.char_count_label.setAccessibleDescription("Character limit exceeded")
|
self.char_count_label.setAccessibleDescription("Character limit exceeded")
|
||||||
else:
|
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):
|
def send_post(self):
|
||||||
"""Send the post"""
|
"""Send the post"""
|
||||||
@@ -271,7 +350,9 @@ class ComposeDialog(QDialog):
|
|||||||
# Get active account
|
# Get active account
|
||||||
active_account = self.account_manager.get_active_account()
|
active_account = self.account_manager.get_active_account()
|
||||||
if not 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
|
return
|
||||||
|
|
||||||
# Get post settings
|
# Get post settings
|
||||||
@@ -280,7 +361,7 @@ class ComposeDialog(QDialog):
|
|||||||
"Public": "public",
|
"Public": "public",
|
||||||
"Unlisted": "unlisted",
|
"Unlisted": "unlisted",
|
||||||
"Followers Only": "private",
|
"Followers Only": "private",
|
||||||
"Direct Message": "direct"
|
"Direct Message": "direct",
|
||||||
}
|
}
|
||||||
visibility = visibility_map.get(visibility_text, "public")
|
visibility = visibility_map.get(visibility_text, "public")
|
||||||
|
|
||||||
@@ -302,14 +383,17 @@ class ComposeDialog(QDialog):
|
|||||||
|
|
||||||
# Validate poll (need at least 2 options)
|
# Validate poll (need at least 2 options)
|
||||||
if len(poll_options) < 2:
|
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
|
return
|
||||||
|
|
||||||
# Create poll data
|
# Create poll data
|
||||||
poll_data = {
|
poll_data = {
|
||||||
'options': poll_options,
|
"options": poll_options,
|
||||||
'expires_in': self.poll_duration.value() * 3600, # Convert hours to seconds
|
"expires_in": self.poll_duration.value()
|
||||||
'multiple': self.poll_multiple.isChecked()
|
* 3600, # Convert hours to seconds
|
||||||
|
"multiple": self.poll_multiple.isChecked(),
|
||||||
}
|
}
|
||||||
|
|
||||||
# Check if we need to upload media first
|
# Check if we need to upload media first
|
||||||
@@ -324,13 +408,13 @@ class ComposeDialog(QDialog):
|
|||||||
|
|
||||||
# Start background posting
|
# Start background posting
|
||||||
post_data = {
|
post_data = {
|
||||||
'account': active_account,
|
"account": active_account,
|
||||||
'content': content,
|
"content": content,
|
||||||
'visibility': visibility,
|
"visibility": visibility,
|
||||||
'content_type': content_type,
|
"content_type": content_type,
|
||||||
'content_warning': content_warning,
|
"content_warning": content_warning,
|
||||||
'poll': poll_data,
|
"poll": poll_data,
|
||||||
'media_ids': media_ids
|
"media_ids": media_ids,
|
||||||
}
|
}
|
||||||
|
|
||||||
# NOTE: Sound handling moved to centralized PostManager to avoid duplication
|
# NOTE: Sound handling moved to centralized PostManager to avoid duplication
|
||||||
@@ -350,7 +434,7 @@ class ComposeDialog(QDialog):
|
|||||||
|
|
||||||
# Get current user's account ID
|
# Get current user's account ID
|
||||||
current_user = client.verify_credentials()
|
current_user = client.verify_credentials()
|
||||||
current_account_id = current_user['id']
|
current_account_id = current_user["id"]
|
||||||
|
|
||||||
# Collect usernames from multiple sources
|
# Collect usernames from multiple sources
|
||||||
usernames = set()
|
usernames = set()
|
||||||
@@ -361,10 +445,14 @@ class ComposeDialog(QDialog):
|
|||||||
search_results = client.search_accounts(prefix, limit=40)
|
search_results = client.search_accounts(prefix, limit=40)
|
||||||
for account in search_results:
|
for account in search_results:
|
||||||
# Use full fediverse handle (acct field) or construct it
|
# Use full fediverse handle (acct field) or construct it
|
||||||
full_handle = account.get('acct', '')
|
full_handle = account.get("acct", "")
|
||||||
if not full_handle:
|
if not full_handle:
|
||||||
username = account.get('username', '')
|
username = account.get("username", "")
|
||||||
domain = account.get('url', '').split('/')[2] if account.get('url') else ''
|
domain = (
|
||||||
|
account.get("url", "").split("/")[2]
|
||||||
|
if account.get("url")
|
||||||
|
else ""
|
||||||
|
)
|
||||||
if username and domain:
|
if username and domain:
|
||||||
full_handle = f"{username}@{domain}"
|
full_handle = f"{username}@{domain}"
|
||||||
else:
|
else:
|
||||||
@@ -380,10 +468,14 @@ class ComposeDialog(QDialog):
|
|||||||
followers = client.get_followers(current_account_id, limit=100)
|
followers = client.get_followers(current_account_id, limit=100)
|
||||||
for follower in followers:
|
for follower in followers:
|
||||||
# Use full fediverse handle
|
# Use full fediverse handle
|
||||||
full_handle = follower.get('acct', '')
|
full_handle = follower.get("acct", "")
|
||||||
if not full_handle:
|
if not full_handle:
|
||||||
username = follower.get('username', '')
|
username = follower.get("username", "")
|
||||||
domain = follower.get('url', '').split('/')[2] if follower.get('url') else ''
|
domain = (
|
||||||
|
follower.get("url", "").split("/")[2]
|
||||||
|
if follower.get("url")
|
||||||
|
else ""
|
||||||
|
)
|
||||||
if username and domain:
|
if username and domain:
|
||||||
full_handle = f"{username}@{domain}"
|
full_handle = f"{username}@{domain}"
|
||||||
else:
|
else:
|
||||||
@@ -399,10 +491,14 @@ class ComposeDialog(QDialog):
|
|||||||
following = client.get_following(current_account_id, limit=100)
|
following = client.get_following(current_account_id, limit=100)
|
||||||
for account in following:
|
for account in following:
|
||||||
# Use full fediverse handle
|
# Use full fediverse handle
|
||||||
full_handle = account.get('acct', '')
|
full_handle = account.get("acct", "")
|
||||||
if not full_handle:
|
if not full_handle:
|
||||||
username = account.get('username', '')
|
username = account.get("username", "")
|
||||||
domain = account.get('url', '').split('/')[2] if account.get('url') else ''
|
domain = (
|
||||||
|
account.get("url", "").split("/")[2]
|
||||||
|
if account.get("url")
|
||||||
|
else ""
|
||||||
|
)
|
||||||
if username and domain:
|
if username and domain:
|
||||||
full_handle = f"{username}@{domain}"
|
full_handle = f"{username}@{domain}"
|
||||||
else:
|
else:
|
||||||
@@ -437,13 +533,17 @@ class ComposeDialog(QDialog):
|
|||||||
def get_post_data(self) -> dict:
|
def get_post_data(self) -> dict:
|
||||||
"""Get the composed post data"""
|
"""Get the composed post data"""
|
||||||
data = {
|
data = {
|
||||||
'content': self.text_edit.toPlainText().strip(),
|
"content": self.text_edit.toPlainText().strip(),
|
||||||
'visibility': self.visibility_combo.currentText().lower().replace(" ", "_"),
|
"visibility": self.visibility_combo.currentText().lower().replace(" ", "_"),
|
||||||
'content_warning': self.cw_edit.toPlainText().strip() if self.cw_checkbox.isChecked() else None
|
"content_warning": (
|
||||||
|
self.cw_edit.toPlainText().strip()
|
||||||
|
if self.cw_checkbox.isChecked()
|
||||||
|
else None
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
# Add media IDs if available
|
# Add media IDs if available
|
||||||
if self.media_upload_widget and self.media_upload_widget.has_media():
|
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
|
return data
|
||||||
@@ -3,9 +3,21 @@ Post details dialog showing favorites, boosts, and other interaction details
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QDialog, QVBoxLayout, QHBoxLayout, QLabel, QTextEdit,
|
QDialog,
|
||||||
QTabWidget, QListWidget, QListWidgetItem, QDialogButtonBox,
|
QVBoxLayout,
|
||||||
QWidget, QGroupBox, QPushButton
|
QHBoxLayout,
|
||||||
|
QLabel,
|
||||||
|
QTextEdit,
|
||||||
|
QTabWidget,
|
||||||
|
QListWidget,
|
||||||
|
QListWidgetItem,
|
||||||
|
QDialogButtonBox,
|
||||||
|
QWidget,
|
||||||
|
QGroupBox,
|
||||||
|
QPushButton,
|
||||||
|
QCheckBox,
|
||||||
|
QRadioButton,
|
||||||
|
QButtonGroup,
|
||||||
)
|
)
|
||||||
from PySide6.QtCore import Qt, Signal, QThread
|
from PySide6.QtCore import Qt, Signal, QThread
|
||||||
from PySide6.QtGui import QFont
|
from PySide6.QtGui import QFont
|
||||||
@@ -27,27 +39,24 @@ class FetchDetailsThread(QThread):
|
|||||||
super().__init__()
|
super().__init__()
|
||||||
self.client = client
|
self.client = client
|
||||||
self.post_id = post_id
|
self.post_id = post_id
|
||||||
self.logger = logging.getLogger('bifrost.post_details')
|
self.logger = logging.getLogger("bifrost.post_details")
|
||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
"""Fetch favorites and boosts in background"""
|
"""Fetch favorites and boosts in background"""
|
||||||
try:
|
try:
|
||||||
details = {
|
details = {"favourited_by": [], "reblogged_by": []}
|
||||||
'favourited_by': [],
|
|
||||||
'reblogged_by': []
|
|
||||||
}
|
|
||||||
|
|
||||||
# Fetch who favorited this post
|
# Fetch who favorited this post
|
||||||
try:
|
try:
|
||||||
favourited_by_data = self.client.get_status_favourited_by(self.post_id)
|
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:
|
except Exception as e:
|
||||||
self.logger.error(f"Failed to fetch favorites: {e}")
|
self.logger.error(f"Failed to fetch favorites: {e}")
|
||||||
|
|
||||||
# Fetch who boosted this post
|
# Fetch who boosted this post
|
||||||
try:
|
try:
|
||||||
reblogged_by_data = self.client.get_status_reblogged_by(self.post_id)
|
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:
|
except Exception as e:
|
||||||
self.logger.error(f"Failed to fetch boosts: {e}")
|
self.logger.error(f"Failed to fetch boosts: {e}")
|
||||||
|
|
||||||
@@ -60,12 +69,18 @@ class FetchDetailsThread(QThread):
|
|||||||
class PostDetailsDialog(QDialog):
|
class PostDetailsDialog(QDialog):
|
||||||
"""Dialog showing detailed post interaction information"""
|
"""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)
|
super().__init__(parent)
|
||||||
self.post = post
|
self.post = post
|
||||||
self.client = client
|
self.client = client
|
||||||
self.sound_manager = sound_manager
|
self.sound_manager = sound_manager
|
||||||
self.logger = logging.getLogger('bifrost.post_details')
|
self.logger = logging.getLogger("bifrost.post_details")
|
||||||
|
|
||||||
self.setWindowTitle("Post Details")
|
self.setWindowTitle("Post Details")
|
||||||
self.setModal(True)
|
self.setModal(True)
|
||||||
@@ -78,41 +93,27 @@ class PostDetailsDialog(QDialog):
|
|||||||
"""Setup the post details UI"""
|
"""Setup the post details UI"""
|
||||||
layout = QVBoxLayout(self)
|
layout = QVBoxLayout(self)
|
||||||
|
|
||||||
# Post content section
|
# Post statistics (keep just the basic stats at top)
|
||||||
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
|
|
||||||
stats_text = f"Replies: {self.post.replies_count} | Boosts: {self.post.reblogs_count} | Favorites: {self.post.favourites_count}"
|
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 = QLabel(stats_text)
|
||||||
stats_label.setAccessibleName("Post Statistics")
|
stats_label.setAccessibleName("Post Statistics")
|
||||||
content_layout.addWidget(stats_label)
|
layout.addWidget(stats_label)
|
||||||
|
|
||||||
layout.addWidget(content_group)
|
|
||||||
|
|
||||||
# Tabs for interaction details
|
# Tabs for interaction details
|
||||||
self.tabs = QTabWidget()
|
self.tabs = QTabWidget()
|
||||||
self.tabs.setAccessibleName("Interaction Details")
|
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
|
# Favorites tab
|
||||||
self.favorites_list = QListWidget()
|
self.favorites_list = QListWidget()
|
||||||
self.favorites_list.setAccessibleName("Users Who Favorited")
|
self.favorites_list.setAccessibleName("Users Who Favorited")
|
||||||
@@ -120,7 +121,9 @@ class PostDetailsDialog(QDialog):
|
|||||||
fake_header = QListWidgetItem("Users who favorited this post:")
|
fake_header = QListWidgetItem("Users who favorited this post:")
|
||||||
fake_header.setFlags(Qt.ItemIsEnabled) # Not selectable
|
fake_header.setFlags(Qt.ItemIsEnabled) # Not selectable
|
||||||
self.favorites_list.addItem(fake_header)
|
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
|
# Boosts tab
|
||||||
self.boosts_list = QListWidget()
|
self.boosts_list = QListWidget()
|
||||||
@@ -146,7 +149,7 @@ class PostDetailsDialog(QDialog):
|
|||||||
|
|
||||||
def load_details(self):
|
def load_details(self):
|
||||||
"""Load detailed interaction information"""
|
"""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")
|
self.status_label.setText("Cannot load details: No post ID or API client")
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -161,7 +164,7 @@ class PostDetailsDialog(QDialog):
|
|||||||
self.status_label.setText("")
|
self.status_label.setText("")
|
||||||
|
|
||||||
# Populate favorites list
|
# Populate favorites list
|
||||||
favourited_by = details.get('favourited_by', [])
|
favourited_by = details.get("favourited_by", [])
|
||||||
if favourited_by:
|
if favourited_by:
|
||||||
for account_data in favourited_by:
|
for account_data in favourited_by:
|
||||||
try:
|
try:
|
||||||
@@ -179,7 +182,7 @@ class PostDetailsDialog(QDialog):
|
|||||||
self.favorites_list.addItem(item)
|
self.favorites_list.addItem(item)
|
||||||
|
|
||||||
# Populate boosts list
|
# Populate boosts list
|
||||||
reblogged_by = details.get('reblogged_by', [])
|
reblogged_by = details.get("reblogged_by", [])
|
||||||
if reblogged_by:
|
if reblogged_by:
|
||||||
for account_data in reblogged_by:
|
for account_data in reblogged_by:
|
||||||
try:
|
try:
|
||||||
@@ -199,8 +202,15 @@ class PostDetailsDialog(QDialog):
|
|||||||
# Update tab titles with actual counts
|
# Update tab titles with actual counts
|
||||||
actual_favorites = len(favourited_by)
|
actual_favorites = len(favourited_by)
|
||||||
actual_boosts = len(reblogged_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
|
# Play success sound
|
||||||
self.sound_manager.play_success()
|
self.sound_manager.play_success()
|
||||||
@@ -218,3 +228,211 @@ class PostDetailsDialog(QDialog):
|
|||||||
|
|
||||||
# Play error sound
|
# Play error sound
|
||||||
self.sound_manager.play_error()
|
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()
|
||||||
|
|||||||
+388
-188
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user