Fix boosted poll handling and improve poll accessibility
- Fix Post.has_poll() and get_poll_info() to check reblog.poll for boosted posts - Remove unused PollVotingDialog class to eliminate code duplication - Fix poll option accessibility by using actual text in setAccessibleName() - Add client-side poll expiration checking with dateutil parsing - Add error dialog handling for failed poll votes with vote_error signal - Restore comprehensive CLAUDE.md documentation after truncation - Add collaborative vibe coding article documenting the development process 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
717
CLAUDE.md
717
CLAUDE.md
@ -6,3 +6,720 @@
|
|||||||
|
|
||||||
## Development Notes
|
## Development Notes
|
||||||
- When testing bifrost use DISPLAY=:0 so that it will start successfully.
|
- When testing bifrost use DISPLAY=:0 so that it will start successfully.
|
||||||
|
|
||||||
|
## Logging System
|
||||||
|
- Bifrost uses a centralized logging system configured in `bifrost.py`
|
||||||
|
- **NEVER use print() statements for debugging** - always use the logging system
|
||||||
|
- To enable debug logging, run bifrost with the `-d` flag:
|
||||||
|
- `DISPLAY=:0 python bifrost.py -d` for console debug output
|
||||||
|
- `DISPLAY=:0 python bifrost.py -d bifrost.log` for debug to file
|
||||||
|
- Use proper logger instances: `logger = logging.getLogger('bifrost.module_name')`
|
||||||
|
- Default logging levels: DEBUG, INFO, WARNING, ERROR
|
||||||
|
- Without `-d` flag, only WARNING and ERROR messages are shown
|
||||||
|
|
||||||
|
## Architecture Notes
|
||||||
|
- **Poll handling**: For boosted/reblogged posts, poll data is in `post.reblog.poll`, not `post.poll`
|
||||||
|
- **Post model**: The `Post` class handles both regular posts and boosts via the `reblog` attribute
|
||||||
|
- **Data access patterns**: Always check for reblogged content when accessing post properties like polls, media, etc.
|
||||||
|
- **API data structures**: Poll options may come as objects with direct properties (e.g., `option.title`) rather than dictionaries requiring `.get('title')`
|
||||||
|
|
||||||
|
## Common Pitfalls
|
||||||
|
- Don't assume poll data is in the main post object for boosts - check `post.reblog.poll`
|
||||||
|
- Don't use dict-style access for API objects without checking if they're actually dictionaries
|
||||||
|
- Always use the logging system for debugging instead of print statements
|
||||||
|
- When adding debug logging, make sure to test with `-d` flag to see the output# Bifrost Fediverse Client - Development Plan
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
Bifrost is a fully accessible fediverse client built with PySide6, designed specifically for screen reader users. The application uses "post/posts" terminology instead
|
||||||
|
of "toot" and focuses on excellent keyboard navigation and audio feedback.
|
||||||
|
|
||||||
|
## Development Workflow
|
||||||
|
- Check for any changes in git project before doing anything else. Make sure the latest changes have been pulled
|
||||||
|
- See what has changed, use git commands and examine the code to make sure you are up to date with the latest code
|
||||||
|
|
||||||
|
## Duplicate Code Prevention Guidelines
|
||||||
|
|
||||||
|
### Critical Areas Requiring Attention
|
||||||
|
1. **Sound/Audio Events**: Never add multiple paths that trigger the same sound event
|
||||||
|
2. **UI Event Handlers**: Avoid circular event chains (A triggers B which triggers A)
|
||||||
|
3. **Timeline Operations**: Coordinate refresh calls and state changes to prevent conflicts
|
||||||
|
4. **Lifecycle Events**: Ensure shutdown, close, and quit events don't overlap
|
||||||
|
4. **Design**: Ensure single point of truth is used as much as possible through out the code. This means functionality should not be duplicated across files.
|
||||||
|
|
||||||
|
### Required Patterns for Event-Heavy Operations
|
||||||
|
|
||||||
|
#### Event Coordination Pattern
|
||||||
|
```python
|
||||||
|
def operation_method(self):
|
||||||
|
if hasattr(self, '_operation_in_progress') and self._operation_in_progress:
|
||||||
|
return
|
||||||
|
self._operation_in_progress = True
|
||||||
|
try:
|
||||||
|
# perform operation
|
||||||
|
self.sound_manager.play_success()
|
||||||
|
finally:
|
||||||
|
# Reset flag after brief delay to prevent rapid-fire calls
|
||||||
|
QTimer.singleShot(100, lambda: setattr(self, '_operation_in_progress', False))
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Event Source Tracking Pattern
|
||||||
|
```python
|
||||||
|
def ui_triggered_method(self, index, from_source="unknown"):
|
||||||
|
# Handle differently based on source: "tab_change", "keyboard", "menu"
|
||||||
|
if from_source == "tab_change":
|
||||||
|
# Skip certain UI updates to prevent circular calls
|
||||||
|
pass
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Lifecycle Event Coordination Pattern
|
||||||
|
```python
|
||||||
|
def quit_application(self):
|
||||||
|
self._shutdown_handled = True # Mark that shutdown is being handled
|
||||||
|
self.sound_manager.play_shutdown()
|
||||||
|
|
||||||
|
def closeEvent(self, event):
|
||||||
|
# Only handle if not already handled by explicit quit
|
||||||
|
if not hasattr(self, '_shutdown_handled'):
|
||||||
|
self.sound_manager.play_shutdown()
|
||||||
|
```
|
||||||
|
|
||||||
|
### Development Review Checklist
|
||||||
|
Before implementing any sound, notification, or UI event:
|
||||||
|
1. **Is there already another code path that triggers this same feedback?**
|
||||||
|
2. **Could this create an event loop (A calls B which calls A)?**
|
||||||
|
3. **Can rapid user actions (keyboard shortcuts) trigger this multiple times?**
|
||||||
|
4. **Does this operation need coordination with other similar operations?**
|
||||||
|
5. **Are there multiple UI elements (menu + button + shortcut) that trigger this?**
|
||||||
|
|
||||||
|
### Common Patterns to Avoid
|
||||||
|
- Multiple event handlers calling the same sound method
|
||||||
|
- UI updates that trigger their own event handlers
|
||||||
|
- Shutdown/close/quit events without coordination
|
||||||
|
- Timeline refresh without checking if already refreshing
|
||||||
|
- Direct sound calls in multiple places for the same user action
|
||||||
|
|
||||||
|
### Testing Requirements
|
||||||
|
When adding event-driven features, always test:
|
||||||
|
- Rapid keyboard shortcut usage
|
||||||
|
- Multiple quick UI interactions
|
||||||
|
- Combinations of keyboard shortcuts + UI clicks
|
||||||
|
- Window closing during operations
|
||||||
|
- Tab switching with keyboard vs mouse
|
||||||
|
|
||||||
|
## Professional Logging System
|
||||||
|
|
||||||
|
Bifrost includes a comprehensive logging system essential for AI-only development. All debugging should use proper logging instead of print statements.
|
||||||
|
|
||||||
|
### Logging Standards
|
||||||
|
|
||||||
|
**Command-line Debug Flags:**
|
||||||
|
- `python bifrost.py -d` - Debug output to console
|
||||||
|
- `python bifrost.py -d filename` - Debug output to file
|
||||||
|
- `python bifrost.py` - Production mode (warnings/errors only)
|
||||||
|
|
||||||
|
**Log Format:** `message - severity - timestamp`
|
||||||
|
```
|
||||||
|
Timeline refresh requested: auto_refresh - DEBUG - 2025-07-22 23:17:33
|
||||||
|
New content detected: newest post changed from abc123 to def456 - INFO - 2025-07-22 23:17:34
|
||||||
|
```
|
||||||
|
|
||||||
|
### Logger Setup Pattern
|
||||||
|
|
||||||
|
Every class should have a logger in `__init__()`:
|
||||||
|
```python
|
||||||
|
import logging
|
||||||
|
|
||||||
|
class MyClass:
|
||||||
|
def __init__(self):
|
||||||
|
self.logger = logging.getLogger('bifrost.module_name')
|
||||||
|
```
|
||||||
|
|
||||||
|
### Logging Guidelines
|
||||||
|
|
||||||
|
**What to Log at Each Level:**
|
||||||
|
|
||||||
|
- **DEBUG**: Method calls, state changes, timing information, execution flow
|
||||||
|
- **INFO**: Important events (new content detected, sounds played, operations completed)
|
||||||
|
- **WARNING**: Recoverable issues (fallbacks, missing optional features, server incompatibilities)
|
||||||
|
- **ERROR**: Serious problems (network failures, invalid data, system errors)
|
||||||
|
- **CRITICAL**: Fatal issues that prevent operation
|
||||||
|
|
||||||
|
**Required Logging Areas:**
|
||||||
|
|
||||||
|
1. **Auto-refresh System**: Timing, triggers, new content detection
|
||||||
|
2. **Sound Events**: Which sounds played, when, and why
|
||||||
|
3. **Network Operations**: API calls, streaming connections, failures
|
||||||
|
4. **User Actions**: Post composition, timeline navigation, settings changes
|
||||||
|
5. **Error Conditions**: All exceptions, fallbacks, and recovery attempts
|
||||||
|
|
||||||
|
**Logging Patterns:**
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Method entry/exit for complex operations
|
||||||
|
self.logger.debug("method_name() called")
|
||||||
|
self.logger.debug("method_name() completed successfully")
|
||||||
|
|
||||||
|
# State changes
|
||||||
|
self.logger.info(f"Timeline switched from {old} to {new}")
|
||||||
|
|
||||||
|
# New content detection
|
||||||
|
self.logger.info(f"New content detected: {count} new posts")
|
||||||
|
|
||||||
|
# Sound events
|
||||||
|
self.logger.info(f"Playing {sound_type} sound for {reason}")
|
||||||
|
|
||||||
|
# Error handling with context
|
||||||
|
self.logger.error(f"Failed to {operation}: {error}")
|
||||||
|
```
|
||||||
|
|
||||||
|
**Never Use Print Statements:**
|
||||||
|
- All output should go through the logging system
|
||||||
|
- Print statements interfere with proper log formatting
|
||||||
|
- Use appropriate log levels instead of printing debug info
|
||||||
|
|
||||||
|
### AI Development Benefits
|
||||||
|
|
||||||
|
This logging system is crucial for AI-only development because:
|
||||||
|
- Provides complete visibility into application behavior
|
||||||
|
- Enables systematic debugging without human intervention
|
||||||
|
- Shows exact timing and causation of events
|
||||||
|
- Facilitates troubleshooting of complex interactions
|
||||||
|
- Maintains clean separation between debug and production modes
|
||||||
|
|
||||||
|
## Documentation and Dependencies
|
||||||
|
- **README Updates**: When adding new functionality or sound events, update README.md with detailed descriptions
|
||||||
|
- **Requirements Management**: Check and update requirements.txt when new dependencies are added
|
||||||
|
- **Sound Pack Documentation**: Document new sound events in both CLAUDE.md and user-facing documentation
|
||||||
|
- **Version Tracking**: Update version numbers and changelog when significant features are added
|
||||||
|
|
||||||
|
## Core Features
|
||||||
|
- Full ActivityPub protocol support (Pleroma and GoToSocial primary targets)
|
||||||
|
- Threaded conversation navigation with collapsible tree view
|
||||||
|
- Comprehensive soundpack management system with secure repository support
|
||||||
|
- Smart autocomplete for mentions (@user@instance.com) and emojis (5,000+ Unicode)
|
||||||
|
- Auto-refresh with intelligent activity-based timing
|
||||||
|
- Screen reader optimized interface with Orca compatibility fixes
|
||||||
|
- XDG Base Directory specification compliance
|
||||||
|
|
||||||
|
## Technology Stack
|
||||||
|
- **PySide6**: Main GUI framework (proven accessibility with existing doom launcher)
|
||||||
|
- **requests**: HTTP client for ActivityPub APIs
|
||||||
|
- **simpleaudio**: Cross-platform audio with subprocess fallback
|
||||||
|
- **emoji**: Comprehensive Unicode emoji library (5,000+ emojis with keyword search)
|
||||||
|
- **plyer**: Cross-platform desktop notifications
|
||||||
|
- **XDG directories**: Configuration and data storage
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Directory Structure
|
||||||
|
```
|
||||||
|
bifrost/
|
||||||
|
├── bifrost/
|
||||||
|
│ ├── __init__.py
|
||||||
|
│ ├── main.py # Application entry point
|
||||||
|
│ ├── accessibility/ # Accessibility widgets and helpers
|
||||||
|
│ │ ├── __init__.py
|
||||||
|
│ │ ├── accessible_tree.py # AccessibleTreeWidget for conversations
|
||||||
|
│ │ └── accessible_combo.py # Enhanced ComboBox from doom launcher
|
||||||
|
│ ├── activitypub/ # Federation protocol handling
|
||||||
|
│ │ ├── __init__.py
|
||||||
|
│ │ ├── client.py # Main ActivityPub client
|
||||||
|
│ │ ├── pleroma.py # Pleroma-specific implementation
|
||||||
|
│ │ └── gotosocial.py # GoToSocial-specific implementation
|
||||||
|
│ ├── models/ # Data models
|
||||||
|
│ │ ├── __init__.py
|
||||||
|
│ │ ├── post.py # Post data structure
|
||||||
|
│ │ ├── user.py # User profiles
|
||||||
|
│ │ ├── timeline.py # Timeline model for QTreeView
|
||||||
|
│ │ └── thread.py # Conversation threading
|
||||||
|
│ ├── widgets/ # Custom UI components
|
||||||
|
│ │ ├── __init__.py
|
||||||
|
│ │ ├── timeline_view.py # Main timeline widget with auto-refresh
|
||||||
|
│ │ ├── compose_dialog.py # Post composition with smart autocomplete
|
||||||
|
│ │ ├── autocomplete_textedit.py # Mention and emoji autocomplete system
|
||||||
|
│ │ ├── settings_dialog.py # Application settings
|
||||||
|
│ │ ├── soundpack_manager_dialog.py # Soundpack repository management
|
||||||
|
│ │ ├── profile_dialog.py # User profile viewer with social actions
|
||||||
|
│ │ ├── post_details_dialog.py # Post interaction details (favorites, boosts)
|
||||||
|
│ │ ├── media_upload_widget.py # Media attachment system with alt text
|
||||||
|
│ │ ├── custom_emoji_manager.py # Instance-specific emoji caching
|
||||||
|
│ │ └── login_dialog.py # Instance login
|
||||||
|
│ ├── audio/ # Sound system
|
||||||
|
│ │ ├── __init__.py
|
||||||
|
│ │ ├── sound_manager.py # Audio notification handler
|
||||||
|
│ │ └── soundpack_manager.py # Secure soundpack installation system
|
||||||
|
│ │ └── sound_pack.py # Sound pack management
|
||||||
|
│ └── config/ # Configuration management
|
||||||
|
│ ├── __init__.py
|
||||||
|
│ ├── settings.py # Settings handler with XDG compliance
|
||||||
|
│ └── accounts.py # Account management
|
||||||
|
├── sounds/ # Sound packs directory
|
||||||
|
│ ├── default/
|
||||||
|
│ │ ├── pack.json
|
||||||
|
│ │ ├── private_message.wav
|
||||||
|
│ │ ├── mention.wav
|
||||||
|
│ │ ├── boost.wav
|
||||||
|
│ │ ├── reply.wav
|
||||||
|
│ │ ├── post_sent.wav
|
||||||
|
│ │ ├── timeline_update.wav
|
||||||
|
│ │ └── notification.wav
|
||||||
|
│ └── doom/ # Example themed sound pack
|
||||||
|
│ ├── pack.json
|
||||||
|
│ └── *.wav files
|
||||||
|
├── tests/
|
||||||
|
│ ├── __init__.py
|
||||||
|
│ ├── test_accessibility.py
|
||||||
|
│ ├── test_activitypub.py
|
||||||
|
│ └── test_audio.py
|
||||||
|
├── requirements.txt
|
||||||
|
├── setup.py
|
||||||
|
└── README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
### XDG Directory Usage
|
||||||
|
- **Config**: `~/.config/bifrost/` - Settings, accounts, current sound pack
|
||||||
|
- **Data**: `~/.local/share/bifrost/` - Sound packs, cached data
|
||||||
|
- **Cache**: `~/.cache/bifrost/` - Temporary files, avatar cache
|
||||||
|
|
||||||
|
## Accessibility Implementation
|
||||||
|
|
||||||
|
### From Doom Launcher Success
|
||||||
|
- **AccessibleComboBox**: Enhanced keyboard navigation (Page Up/Down, Home/End)
|
||||||
|
- **Proper Accessible Names**: All widgets get descriptive `setAccessibleName()`
|
||||||
|
- **Focus Management**: Clear tab order and focus indicators
|
||||||
|
- **No Custom Speech**: Screen reader handles all announcements
|
||||||
|
|
||||||
|
### Threaded Conversation Navigation
|
||||||
|
**Navigation Pattern:**
|
||||||
|
```
|
||||||
|
Timeline Item: "Alice posted: Hello world (3 replies, collapsed)"
|
||||||
|
[Right Arrow] → "Alice posted: Hello world (3 replies, expanded)"
|
||||||
|
[Down Arrow] → " Bob replied: Hi there"
|
||||||
|
[Down Arrow] → " Carol replied: How's it going?"
|
||||||
|
[Down Arrow] → "David posted: Another topic"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key Behaviors:**
|
||||||
|
- Right Arrow: Expand thread, announce "expanded" / Move to first child when expanded
|
||||||
|
- Left Arrow: Collapse thread, announce "collapsed" / Move to parent
|
||||||
|
- Shift+Left Arrow: Navigate to thread root from any reply
|
||||||
|
- Down Arrow: Next item (skip collapsed children)
|
||||||
|
- Up Arrow: Previous item
|
||||||
|
- Page Down/Up: Jump 5 items
|
||||||
|
- Home/End: First/last item
|
||||||
|
|
||||||
|
### Known Qt Tree Widget Display Quirk
|
||||||
|
|
||||||
|
Due to Qt's visual display synchronization, thread collapse may require double-operation:
|
||||||
|
- Navigation logic works correctly after first collapse (skips collapsed items)
|
||||||
|
- Visual display may need expand→collapse cycle to fully sync
|
||||||
|
- Workaround: Navigate to root with Shift+Left, then Left→Right→Left if needed
|
||||||
|
|
||||||
|
### AccessibleTreeWidget Requirements
|
||||||
|
- Inherit from QTreeWidget
|
||||||
|
- Override keyPressEvent for custom navigation
|
||||||
|
- Proper accessibility roles and states
|
||||||
|
- Focus management for nested items
|
||||||
|
- Status announcements via Qt accessibility
|
||||||
|
|
||||||
|
## Sound System Design
|
||||||
|
|
||||||
|
### Sound Events
|
||||||
|
- **startup**: Application started
|
||||||
|
- **shutdown**: Application closing
|
||||||
|
- **private_message**: Direct message received
|
||||||
|
- **mention**: User mentioned in post
|
||||||
|
- **boost**: Post boosted/reblogged
|
||||||
|
- **reply**: Reply to user's post
|
||||||
|
- **post_sent**: User successfully posted
|
||||||
|
- **timeline_update**: New posts in timeline
|
||||||
|
- **notification**: General notification
|
||||||
|
- **expand**: Thread expanded
|
||||||
|
- **collapse**: Thread collapsed
|
||||||
|
- **success**: General success feedback
|
||||||
|
- **error**: Error occurred
|
||||||
|
- **autocomplete**: Autocomplete suggestions available
|
||||||
|
- **autocomplete_end**: Autocomplete interaction ended
|
||||||
|
|
||||||
|
### Sound System Policy
|
||||||
|
- **No Sound Generation**: The application should never generate sounds programmatically
|
||||||
|
- **Sound Pack Reliance**: All audio feedback must come from sound pack files
|
||||||
|
- **Default Pack Requirement**: A complete default sound pack must ship with the project
|
||||||
|
- **Themed Packs**: Users can install additional themed sound packs (like Doom)
|
||||||
|
- If someone requests sound generation, gently remind them that all sounds should be covered by sound packs
|
||||||
|
|
||||||
|
### Sound Pack Structure
|
||||||
|
**pack.json Format:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "Pack Display Name",
|
||||||
|
"description": "Pack description",
|
||||||
|
"author": "Creator name",
|
||||||
|
"version": "1.0",
|
||||||
|
"sounds": {
|
||||||
|
"private_message": "filename.wav",
|
||||||
|
"mention": "filename.wav",
|
||||||
|
"boost": "filename.wav",
|
||||||
|
"reply": "filename.wav",
|
||||||
|
"post_sent": "filename.wav",
|
||||||
|
"timeline_update": "filename.wav",
|
||||||
|
"notification": "filename.wav"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### SoundManager Features
|
||||||
|
- simpleaudio for cross-platform WAV playback with volume control
|
||||||
|
- Subprocess fallback (sox/play on Linux, afplay on macOS, PowerShell on Windows)
|
||||||
|
- Fallback to default pack if sound missing
|
||||||
|
- Single volume control for all sound pack audio
|
||||||
|
- "None" sound pack option to disable all sounds
|
||||||
|
- Pack discovery and validation
|
||||||
|
- Smart threading (direct calls for simpleaudio, threaded for subprocess)
|
||||||
|
|
||||||
|
## ActivityPub Implementation
|
||||||
|
|
||||||
|
### Core Client Features
|
||||||
|
- **Timeline Streaming**: Real-time updates via WebSocket/polling
|
||||||
|
- **Post Composition**: Text, media attachments, visibility settings
|
||||||
|
- **Thread Resolution**: Fetch complete conversation trees
|
||||||
|
- **User Profiles**: Following, followers, profile viewing
|
||||||
|
- **Notifications**: Mentions, boosts, follows, favorites
|
||||||
|
|
||||||
|
### Server Compatibility
|
||||||
|
**Primary Targets:**
|
||||||
|
- Pleroma: Full feature support
|
||||||
|
- GoToSocial: Full feature support
|
||||||
|
|
||||||
|
**Extended Support:**
|
||||||
|
- Mastodon: Best effort compatibility
|
||||||
|
- Other ActivityPub servers: Basic functionality
|
||||||
|
|
||||||
|
### API Endpoints Usage
|
||||||
|
- `/api/v1/timelines/home` - Home timeline
|
||||||
|
- `/api/v1/statuses` - Post creation
|
||||||
|
- `/api/v1/statuses/:id/context` - Thread fetching
|
||||||
|
- `/api/v1/streaming` - Real-time updates
|
||||||
|
- `/api/v1/notifications` - Notification management
|
||||||
|
|
||||||
|
## User Interface Design
|
||||||
|
|
||||||
|
### Main Window Layout
|
||||||
|
```
|
||||||
|
[Menu Bar]
|
||||||
|
[Instance/Account Selector]
|
||||||
|
[Timeline Tree View - Main Focus]
|
||||||
|
[Compose Box]
|
||||||
|
[Status Bar]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key UI Components
|
||||||
|
- **Timeline View**: AccessibleTreeWidget showing posts and threads with pagination
|
||||||
|
- **Timeline Tabs**: Home, Mentions, Local, Federated timeline switching
|
||||||
|
- **Compose Dialog**: Modal for creating posts with accessibility
|
||||||
|
- **Settings Dialog**: Sound pack, desktop notifications, accessibility options
|
||||||
|
- **Login Dialog**: Instance selection and authentication
|
||||||
|
- **URL Selection Dialog**: Choose from multiple URLs in posts
|
||||||
|
- **Context Menu**: Copy, URL opening, reply, boost, favorite actions
|
||||||
|
|
||||||
|
### Keyboard Shortcuts
|
||||||
|
- **Ctrl+N**: New post
|
||||||
|
- **Ctrl+R**: Reply to selected post
|
||||||
|
- **Ctrl+B**: Boost selected post
|
||||||
|
- **Ctrl+F**: Favorite selected post
|
||||||
|
- **Ctrl+C**: Copy selected post to clipboard
|
||||||
|
- **Ctrl+U**: Open URLs from selected post in browser
|
||||||
|
- **F5**: Refresh timeline
|
||||||
|
- **Ctrl+,**: Settings
|
||||||
|
- **Escape**: Close dialogs
|
||||||
|
|
||||||
|
## Development Phases
|
||||||
|
|
||||||
|
### Phase 1: Foundation
|
||||||
|
1. Project structure setup
|
||||||
|
2. XDG configuration system
|
||||||
|
3. Basic PySide6 window with accessibility
|
||||||
|
4. AccessibleTreeWidget implementation
|
||||||
|
5. Sound system foundation
|
||||||
|
|
||||||
|
### Phase 2: ActivityPub Core
|
||||||
|
1. Basic HTTP client
|
||||||
|
2. Authentication (OAuth2)
|
||||||
|
3. Timeline fetching and display
|
||||||
|
4. Post composition and sending
|
||||||
|
5. Basic thread display
|
||||||
|
|
||||||
|
### Phase 3: Advanced Features
|
||||||
|
1. Thread expansion/collapse
|
||||||
|
2. Real-time updates
|
||||||
|
3. Notifications system
|
||||||
|
4. Sound pack system
|
||||||
|
5. Settings interface
|
||||||
|
|
||||||
|
### Phase 4: Polish
|
||||||
|
1. Comprehensive accessibility testing
|
||||||
|
2. Screen reader testing (Orca, NVDA, JAWS)
|
||||||
|
3. Performance optimization
|
||||||
|
4. Error handling
|
||||||
|
5. Documentation
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
### Accessibility Testing
|
||||||
|
- Automated testing with screen reader APIs
|
||||||
|
- Manual testing with Orca, NVDA (via Wine)
|
||||||
|
- Keyboard-only navigation testing
|
||||||
|
- Focus management verification
|
||||||
|
|
||||||
|
### Functional Testing
|
||||||
|
- ActivityPub protocol compliance
|
||||||
|
- Thread navigation accuracy
|
||||||
|
- Sound system reliability
|
||||||
|
- Configuration persistence
|
||||||
|
|
||||||
|
### Test Instances
|
||||||
|
- Pleroma test server
|
||||||
|
- GoToSocial test server
|
||||||
|
- Mock ActivityPub server for edge cases
|
||||||
|
|
||||||
|
## Configuration Management
|
||||||
|
|
||||||
|
### Settings Structure
|
||||||
|
```ini
|
||||||
|
[general]
|
||||||
|
instance_url = https://example.social
|
||||||
|
username = user
|
||||||
|
timeline_refresh_interval = 60
|
||||||
|
auto_refresh_enabled = true
|
||||||
|
|
||||||
|
[audio]
|
||||||
|
sound_pack = default
|
||||||
|
volume = 100
|
||||||
|
|
||||||
|
[notifications]
|
||||||
|
enabled = true
|
||||||
|
direct_messages = true
|
||||||
|
mentions = true
|
||||||
|
boosts = false
|
||||||
|
favorites = false
|
||||||
|
follows = true
|
||||||
|
|
||||||
|
[timeline]
|
||||||
|
posts_per_page = 40
|
||||||
|
|
||||||
|
[accessibility]
|
||||||
|
announce_thread_state = true
|
||||||
|
auto_expand_mentions = false
|
||||||
|
keyboard_navigation_wrap = true
|
||||||
|
page_step_size = 5
|
||||||
|
verbose_announcements = true
|
||||||
|
```
|
||||||
|
|
||||||
|
### Account Storage
|
||||||
|
- Secure credential storage
|
||||||
|
- Multiple account support
|
||||||
|
- Instance-specific settings
|
||||||
|
|
||||||
|
## Known Challenges and Solutions
|
||||||
|
|
||||||
|
### ActivityPub Complexity
|
||||||
|
- **Challenge**: Different server implementations vary
|
||||||
|
- **Solution**: Modular client design with server-specific adapters
|
||||||
|
|
||||||
|
### Screen Reader Performance
|
||||||
|
- **Challenge**: Large timelines may impact performance
|
||||||
|
- **Solution**: Virtual scrolling, lazy loading, efficient tree models
|
||||||
|
|
||||||
|
### Thread Visualization
|
||||||
|
- **Challenge**: Complex thread structures hard to navigate
|
||||||
|
- **Solution**: Clear indentation, status announcements, skip collapsed
|
||||||
|
|
||||||
|
### Sound Customization
|
||||||
|
- **Challenge**: Users want different audio feedback
|
||||||
|
- **Solution**: Comprehensive sound pack system with easy installation
|
||||||
|
|
||||||
|
## Recently Implemented Features
|
||||||
|
|
||||||
|
### Completed Features
|
||||||
|
- **Direct Message Interface**: ✅ Dedicated Messages tab with conversation threading
|
||||||
|
- **Bookmarks Tab**: ✅ Timeline tab for viewing saved/bookmarked posts
|
||||||
|
- **Poll Support**: ✅ Create and vote on polls with accessible interface
|
||||||
|
- **Poll Creation**: ✅ Add poll options to compose dialog with expiration times
|
||||||
|
- **Poll Voting**: ✅ Accessible poll interaction with keyboard navigation
|
||||||
|
- **User Profile Viewer**: ✅ Comprehensive profile dialog with bio, fields, recent posts
|
||||||
|
- **Social Actions**: ✅ Follow/unfollow, block/unblock, mute/unmute from profile viewer
|
||||||
|
|
||||||
|
### Remaining High Priority Features
|
||||||
|
- **User Blocking Management**: Block/unblock users with dedicated management interface
|
||||||
|
- **User Muting Management**: Mute/unmute users with management interface
|
||||||
|
- **Blocked Users Management**: Tab/dialog to view and manage blocked users
|
||||||
|
|
||||||
|
### Implementation Status
|
||||||
|
- Timeline tabs completed: Home, Messages, Mentions, Local, Federated, Bookmarks, Followers, Following
|
||||||
|
- Profile viewer includes all social actions (follow, block, mute) with API integration
|
||||||
|
- Poll accessibility fully implemented with screen reader announcements
|
||||||
|
- DM interface shows conversation threads with proper threading
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
### Advanced Features
|
||||||
|
- Custom timeline filters
|
||||||
|
- Multiple column support
|
||||||
|
- List management
|
||||||
|
- Advanced search
|
||||||
|
|
||||||
|
### Accessibility Extensions
|
||||||
|
- Braille display optimization
|
||||||
|
- Voice control integration
|
||||||
|
- High contrast themes
|
||||||
|
- Font size scaling
|
||||||
|
|
||||||
|
### Federation Features
|
||||||
|
- Cross-instance thread following
|
||||||
|
- Server switching
|
||||||
|
- Federation status monitoring
|
||||||
|
- Custom emoji support
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
### Core Requirements
|
||||||
|
```
|
||||||
|
PySide6>=6.0.0
|
||||||
|
requests>=2.25.0
|
||||||
|
simpleaudio>=1.0.4
|
||||||
|
plyer>=2.1.0
|
||||||
|
emoji>=2.0.0
|
||||||
|
```
|
||||||
|
|
||||||
|
### Optional Dependencies
|
||||||
|
```
|
||||||
|
pytest (testing)
|
||||||
|
coverage (test coverage)
|
||||||
|
black (code formatting)
|
||||||
|
mypy (type checking)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Installation and Distribution
|
||||||
|
|
||||||
|
### Development Setup
|
||||||
|
```bash
|
||||||
|
git clone <repository>
|
||||||
|
cd bifrost
|
||||||
|
pip install -r requirements.txt
|
||||||
|
# Run with proper display
|
||||||
|
DISPLAY=:0 ./bifrost.py
|
||||||
|
# Or
|
||||||
|
python bifrost.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### Packaging
|
||||||
|
- Python wheel distribution
|
||||||
|
- AppImage for Linux
|
||||||
|
- Consideration for distro packages
|
||||||
|
|
||||||
|
## Accessibility Compliance
|
||||||
|
|
||||||
|
### Standards Adherence
|
||||||
|
- WCAG 2.1 AA compliance
|
||||||
|
- Qt Accessibility framework usage
|
||||||
|
- AT-SPI2 protocol support (Linux)
|
||||||
|
- Platform accessibility APIs
|
||||||
|
|
||||||
|
### Screen Reader Testing Matrix
|
||||||
|
- **Orca** (Linux): Primary target
|
||||||
|
- **NVDA** (Windows via Wine): Secondary
|
||||||
|
- **JAWS** (Windows via Wine): Basic compatibility
|
||||||
|
- **VoiceOver** (macOS): Future consideration
|
||||||
|
|
||||||
|
## Critical Accessibility Rules
|
||||||
|
|
||||||
|
### Text Truncation Is Forbidden
|
||||||
|
**NEVER TRUNCATE TEXT**: Bifrost is an accessibility-first client. Text truncation (using "..." or limiting character counts) is strictly forbidden as it prevents screen
|
||||||
|
reader users from accessing complete information. Always display full content, descriptions, usernames, profiles, and any other text in its entirety.
|
||||||
|
|
||||||
|
Examples of forbidden practices:
|
||||||
|
- `content[:100] + "..."`
|
||||||
|
- Character limits on display text
|
||||||
|
- Shortened usernames or descriptions
|
||||||
|
- Abbreviated profile information
|
||||||
|
|
||||||
|
### Screen Reader Accessibility Guidelines
|
||||||
|
|
||||||
|
#### Widget Choice and Layout
|
||||||
|
**DO:**
|
||||||
|
- Use `QTextEdit` (read-only) for all text content that needs to be accessible
|
||||||
|
- Use `QListWidget` with fake header items for navigable lists
|
||||||
|
- Set proper `setAccessibleName()` on all interactive widgets
|
||||||
|
- Enable keyboard navigation with `Qt.TextSelectableByKeyboard`
|
||||||
|
|
||||||
|
**DON'T:**
|
||||||
|
- Use `QLabel` for important information - screen readers often skip labels
|
||||||
|
- Use disabled `QCheckBox` or `QRadioButton` widgets - they get skipped
|
||||||
|
- Create single-item lists without fake headers - they're hard to navigate
|
||||||
|
|
||||||
|
#### Accessible List Pattern
|
||||||
|
For any list that users need to navigate (polls, favorites, boosts):
|
||||||
|
```python
|
||||||
|
list_widget = QListWidget()
|
||||||
|
list_widget.setAccessibleName("Descriptive List Name")
|
||||||
|
|
||||||
|
# Add fake header for single-item navigation
|
||||||
|
fake_header = QListWidgetItem("List title:")
|
||||||
|
fake_header.setFlags(Qt.ItemIsEnabled) # Not selectable
|
||||||
|
list_widget.addItem(fake_header)
|
||||||
|
|
||||||
|
# Add actual items
|
||||||
|
for item in items:
|
||||||
|
list_item = QListWidgetItem(item_text)
|
||||||
|
list_item.setData(Qt.UserRole, item_data)
|
||||||
|
list_widget.addItem(list_item)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Context Menu Accessibility
|
||||||
|
**Required keyboard shortcuts for context menus:**
|
||||||
|
- Applications key (`Qt.Key_Menu`)
|
||||||
|
- Shift+F10 (`Qt.Key_F10` with `Qt.ShiftModifier`)
|
||||||
|
|
||||||
|
Both must trigger: `self.customContextMenuRequested.emit(center)`
|
||||||
|
|
||||||
|
#### Interactive vs Display Content
|
||||||
|
**For expired/completed interactive elements (polls, forms):**
|
||||||
|
- Replace disabled controls with accessible display lists
|
||||||
|
- Show results using the accessible list pattern
|
||||||
|
- Never leave users with disabled widgets they can't interact with
|
||||||
|
|
||||||
|
**For live interactive elements:**
|
||||||
|
- Use standard Qt controls (`QCheckBox`, `QRadioButton`, `QPushButton`)
|
||||||
|
- Ensure all controls have meaningful `setAccessibleName()`
|
||||||
|
- Provide clear feedback for actions
|
||||||
|
|
||||||
|
#### Information Display Priority
|
||||||
|
**All important information must be in accessible widgets:**
|
||||||
|
1. **Primary location:** `QTextEdit` (read-only, keyboard selectable)
|
||||||
|
2. **Secondary location:** `QListWidget` items
|
||||||
|
3. **Never primary:** `QLabel` widgets (often skipped)
|
||||||
|
|
||||||
|
**Example of accessible content display:**
|
||||||
|
```python
|
||||||
|
content_text = QTextEdit()
|
||||||
|
content_text.setAccessibleName("Full Post Details")
|
||||||
|
content_text.setPlainText(f"Author: @{username}\n\nContent:\n{post_content}\n\nMetadata:\n{details}")
|
||||||
|
content_text.setReadOnly(True)
|
||||||
|
content_text.setTextInteractionFlags(Qt.TextSelectableByKeyboard | Qt.TextSelectableByMouse)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Dialog and Tab Design
|
||||||
|
**Avoid content duplication:**
|
||||||
|
- Don't show the same information in multiple places
|
||||||
|
- Use tabs to organize different types of information
|
||||||
|
- Keep essential stats/counts at the top level
|
||||||
|
- Put detailed content in dedicated tabs
|
||||||
|
|
||||||
|
**Tab organization example:**
|
||||||
|
- Statistics: Brief counts at dialog top
|
||||||
|
- Poll tab: Interactive voting or accessible results list
|
||||||
|
- Content tab: Full post details in accessible text widget
|
||||||
|
- Interaction tabs: Who favorited/boosted in accessible lists
|
||||||
|
|
||||||
|
This document serves as the comprehensive development guide for Bifrost, ensuring all accessibility, functionality, and architectural decisions are preserved and can be
|
||||||
|
referenced throughout development.
|
||||||
|
@ -333,14 +333,20 @@ class Post:
|
|||||||
|
|
||||||
def has_poll(self) -> bool:
|
def has_poll(self) -> bool:
|
||||||
"""Check if this post has a poll"""
|
"""Check if this post has a poll"""
|
||||||
|
if self.reblog:
|
||||||
|
return self.reblog.poll is not None
|
||||||
return self.poll is not None
|
return self.poll is not None
|
||||||
|
|
||||||
def get_poll_info(self) -> str:
|
def get_poll_info(self) -> str:
|
||||||
"""Get accessible poll information"""
|
"""Get accessible poll information"""
|
||||||
if not self.poll:
|
# For boosted posts, get poll from the reblogged post
|
||||||
return ""
|
poll = None
|
||||||
|
if self.reblog and self.reblog.poll:
|
||||||
|
poll = self.reblog.poll
|
||||||
|
elif self.poll:
|
||||||
poll = self.poll
|
poll = self.poll
|
||||||
|
else:
|
||||||
|
return ""
|
||||||
options_count = len(poll.get('options', []))
|
options_count = len(poll.get('options', []))
|
||||||
expires_at = poll.get('expires_at')
|
expires_at = poll.get('expires_at')
|
||||||
multiple = poll.get('multiple', False)
|
multiple = poll.get('multiple', False)
|
||||||
|
@ -1,226 +0,0 @@
|
|||||||
"""
|
|
||||||
Poll voting dialog for voting in fediverse polls
|
|
||||||
"""
|
|
||||||
|
|
||||||
from PySide6.QtWidgets import (
|
|
||||||
QDialog, QVBoxLayout, QHBoxLayout, QLabel,
|
|
||||||
QDialogButtonBox, QPushButton, QCheckBox, QRadioButton,
|
|
||||||
QGroupBox, QButtonGroup, QListWidget, QListWidgetItem
|
|
||||||
)
|
|
||||||
from PySide6.QtCore import Qt, Signal
|
|
||||||
from PySide6.QtGui import QKeySequence, QShortcut
|
|
||||||
from typing import List, Optional, Dict, Any
|
|
||||||
|
|
||||||
|
|
||||||
class PollVotingDialog(QDialog):
|
|
||||||
"""Dialog for voting in polls"""
|
|
||||||
|
|
||||||
vote_submitted = Signal(list) # Emitted with list of selected choice indices
|
|
||||||
|
|
||||||
def __init__(self, poll_data: Dict[str, Any], parent=None):
|
|
||||||
super().__init__(parent)
|
|
||||||
self.poll_data = poll_data
|
|
||||||
self.option_widgets = []
|
|
||||||
self.button_group = None
|
|
||||||
self.setup_ui()
|
|
||||||
self.setup_shortcuts()
|
|
||||||
|
|
||||||
def setup_ui(self):
|
|
||||||
"""Initialize the poll voting dialog UI"""
|
|
||||||
poll = self.poll_data
|
|
||||||
options = poll.get('options', [])
|
|
||||||
multiple = poll.get('multiple', False)
|
|
||||||
expired = poll.get('expired', False)
|
|
||||||
voted = poll.get('voted', False)
|
|
||||||
votes_count = poll.get('votes_count', 0)
|
|
||||||
|
|
||||||
self.setWindowTitle("Vote in Poll")
|
|
||||||
self.setMinimumSize(400, 300)
|
|
||||||
self.setModal(True)
|
|
||||||
|
|
||||||
layout = QVBoxLayout(self)
|
|
||||||
|
|
||||||
# Poll info
|
|
||||||
info_text = f"Poll with {len(options)} options"
|
|
||||||
if votes_count > 0:
|
|
||||||
info_text += f" ({votes_count} votes)"
|
|
||||||
if expired:
|
|
||||||
info_text += " - EXPIRED"
|
|
||||||
elif voted:
|
|
||||||
info_text += " - Already voted"
|
|
||||||
|
|
||||||
info_label = QLabel(info_text)
|
|
||||||
info_label.setAccessibleName("Poll Information")
|
|
||||||
layout.addWidget(info_label)
|
|
||||||
|
|
||||||
# Instructions
|
|
||||||
if expired:
|
|
||||||
instructions = QLabel("This poll has expired and voting is no longer possible.")
|
|
||||||
elif voted:
|
|
||||||
instructions = QLabel("You have already voted in this poll. You cannot vote again.")
|
|
||||||
elif multiple:
|
|
||||||
instructions = QLabel("Select one or more options by checking the boxes, then click Vote.")
|
|
||||||
else:
|
|
||||||
instructions = QLabel("Select one option by clicking the radio button, then click Vote.")
|
|
||||||
|
|
||||||
instructions.setAccessibleName("Voting Instructions")
|
|
||||||
instructions.setWordWrap(True)
|
|
||||||
layout.addWidget(instructions)
|
|
||||||
|
|
||||||
# Poll options
|
|
||||||
options_group = QGroupBox("Poll Options")
|
|
||||||
options_layout = QVBoxLayout(options_group)
|
|
||||||
|
|
||||||
if not multiple:
|
|
||||||
# Radio buttons for single choice
|
|
||||||
self.button_group = QButtonGroup()
|
|
||||||
|
|
||||||
for i, option in enumerate(options):
|
|
||||||
title = option.get('title', f'Option {i+1}')
|
|
||||||
votes = option.get('votes_count', 0)
|
|
||||||
|
|
||||||
if voted or expired:
|
|
||||||
# Show results with vote counts - collect text for text box
|
|
||||||
percentage = 0
|
|
||||||
if votes_count > 0:
|
|
||||||
percentage = (votes / votes_count) * 100
|
|
||||||
option_text = f"{title}: {votes} votes ({percentage:.1f}%)"
|
|
||||||
|
|
||||||
# Store result text (we'll create text box after loop)
|
|
||||||
if not hasattr(self, 'results_text'):
|
|
||||||
self.results_text = []
|
|
||||||
self.results_text.append(option_text)
|
|
||||||
else:
|
|
||||||
# Show voting options
|
|
||||||
if multiple:
|
|
||||||
# Checkbox for multiple choice
|
|
||||||
option_widget = QCheckBox(title)
|
|
||||||
else:
|
|
||||||
# Radio button for single choice
|
|
||||||
option_widget = QRadioButton(title)
|
|
||||||
self.button_group.addButton(option_widget, i)
|
|
||||||
|
|
||||||
options_layout.addWidget(option_widget)
|
|
||||||
self.option_widgets.append(option_widget)
|
|
||||||
|
|
||||||
# Add results list if showing results
|
|
||||||
if voted or expired and hasattr(self, 'results_text'):
|
|
||||||
results_list = QListWidget()
|
|
||||||
results_list.setAccessibleName("Poll Results")
|
|
||||||
results_list.setMaximumHeight(150)
|
|
||||||
|
|
||||||
# Add header item
|
|
||||||
header_item = QListWidgetItem("Poll Results:")
|
|
||||||
header_item.setFlags(header_item.flags() & ~Qt.ItemIsSelectable)
|
|
||||||
results_list.addItem(header_item)
|
|
||||||
|
|
||||||
# Add each result as a list item
|
|
||||||
for result_text in self.results_text:
|
|
||||||
item = QListWidgetItem(result_text)
|
|
||||||
item.setFlags(item.flags() & ~Qt.ItemIsSelectable)
|
|
||||||
results_list.addItem(item)
|
|
||||||
|
|
||||||
options_layout.addWidget(results_list)
|
|
||||||
|
|
||||||
# Focus on results list for reading
|
|
||||||
self.results_widget = results_list
|
|
||||||
|
|
||||||
layout.addWidget(options_group)
|
|
||||||
|
|
||||||
# Button box
|
|
||||||
button_box = QDialogButtonBox()
|
|
||||||
|
|
||||||
if not expired and not voted:
|
|
||||||
# Vote button
|
|
||||||
self.vote_button = QPushButton("&Vote")
|
|
||||||
self.vote_button.setAccessibleName("Submit Vote")
|
|
||||||
self.vote_button.setDefault(True)
|
|
||||||
self.vote_button.clicked.connect(self.submit_vote)
|
|
||||||
button_box.addButton(self.vote_button, QDialogButtonBox.AcceptRole)
|
|
||||||
|
|
||||||
# Close button
|
|
||||||
close_button = QPushButton("&Close")
|
|
||||||
close_button.setAccessibleName("Close Dialog")
|
|
||||||
close_button.clicked.connect(self.reject)
|
|
||||||
button_box.addButton(close_button, QDialogButtonBox.RejectRole)
|
|
||||||
|
|
||||||
layout.addWidget(button_box)
|
|
||||||
|
|
||||||
# Set initial focus
|
|
||||||
if hasattr(self, 'results_widget'):
|
|
||||||
# Focus on results text box for reading
|
|
||||||
self.results_widget.setFocus()
|
|
||||||
elif self.option_widgets:
|
|
||||||
# Set initial focus on first option
|
|
||||||
self.option_widgets[0].setFocus()
|
|
||||||
elif hasattr(self, 'vote_button'):
|
|
||||||
# Focus on vote button if no options
|
|
||||||
self.vote_button.setFocus()
|
|
||||||
else:
|
|
||||||
# Focus on close button if no voting
|
|
||||||
close_button.setFocus()
|
|
||||||
|
|
||||||
def setup_shortcuts(self):
|
|
||||||
"""Set up keyboard shortcuts"""
|
|
||||||
# Escape to close
|
|
||||||
cancel_shortcut = QShortcut(QKeySequence.Cancel, self)
|
|
||||||
cancel_shortcut.activated.connect(self.reject)
|
|
||||||
|
|
||||||
# Enter to vote (if voting is possible)
|
|
||||||
if hasattr(self, 'vote_button'):
|
|
||||||
vote_shortcut = QShortcut(QKeySequence("Return"), self)
|
|
||||||
vote_shortcut.activated.connect(self.submit_vote)
|
|
||||||
|
|
||||||
def submit_vote(self):
|
|
||||||
"""Submit the vote"""
|
|
||||||
if not self.option_widgets:
|
|
||||||
return
|
|
||||||
|
|
||||||
selected_indices = []
|
|
||||||
multiple = self.poll_data.get('multiple', False)
|
|
||||||
|
|
||||||
if multiple:
|
|
||||||
# Get checked checkboxes
|
|
||||||
for i, widget in enumerate(self.option_widgets):
|
|
||||||
if widget.isChecked():
|
|
||||||
selected_indices.append(i)
|
|
||||||
else:
|
|
||||||
# Get selected radio button
|
|
||||||
for i, widget in enumerate(self.option_widgets):
|
|
||||||
if widget.isChecked():
|
|
||||||
selected_indices.append(i)
|
|
||||||
break
|
|
||||||
|
|
||||||
if not selected_indices:
|
|
||||||
# No selection made
|
|
||||||
return
|
|
||||||
|
|
||||||
# Emit the vote
|
|
||||||
self.vote_submitted.emit(selected_indices)
|
|
||||||
self.accept()
|
|
||||||
|
|
||||||
def get_poll_summary(self) -> str:
|
|
||||||
"""Get accessible summary of the poll for screen readers"""
|
|
||||||
poll = self.poll_data
|
|
||||||
options = poll.get('options', [])
|
|
||||||
multiple = poll.get('multiple', False)
|
|
||||||
expired = poll.get('expired', False)
|
|
||||||
voted = poll.get('voted', False)
|
|
||||||
votes_count = poll.get('votes_count', 0)
|
|
||||||
|
|
||||||
summary = f"Poll with {len(options)} options"
|
|
||||||
if votes_count > 0:
|
|
||||||
summary += f", {votes_count} total votes"
|
|
||||||
if multiple:
|
|
||||||
summary += ", multiple choices allowed"
|
|
||||||
else:
|
|
||||||
summary += ", single choice only"
|
|
||||||
|
|
||||||
if expired:
|
|
||||||
summary += ", expired"
|
|
||||||
elif voted:
|
|
||||||
summary += ", already voted"
|
|
||||||
else:
|
|
||||||
summary += ", voting available"
|
|
||||||
|
|
||||||
return summary
|
|
@ -72,6 +72,7 @@ class PostDetailsDialog(QDialog):
|
|||||||
vote_submitted = Signal(
|
vote_submitted = Signal(
|
||||||
object, list
|
object, list
|
||||||
) # Emitted with post and list of selected choice indices
|
) # Emitted with post and list of selected choice indices
|
||||||
|
vote_error = Signal(str) # Emitted when vote submission fails
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self, post, client: ActivityPubClient, sound_manager: SoundManager, parent=None
|
self, post, client: ActivityPubClient, sound_manager: SoundManager, parent=None
|
||||||
@ -104,7 +105,7 @@ class PostDetailsDialog(QDialog):
|
|||||||
self.tabs.setAccessibleName("Interaction Details")
|
self.tabs.setAccessibleName("Interaction Details")
|
||||||
|
|
||||||
# Poll tab (if poll exists) - add as first tab
|
# Poll tab (if poll exists) - add as first tab
|
||||||
if hasattr(self.post, "poll") and self.post.poll:
|
if self.post.has_poll():
|
||||||
self.poll_widget = self.create_poll_widget()
|
self.poll_widget = self.create_poll_widget()
|
||||||
poll_tab_index = self.tabs.addTab(self.poll_widget, "Poll")
|
poll_tab_index = self.tabs.addTab(self.poll_widget, "Poll")
|
||||||
self.logger.debug(f"Added poll tab at index {poll_tab_index}")
|
self.logger.debug(f"Added poll tab at index {poll_tab_index}")
|
||||||
@ -205,7 +206,7 @@ class PostDetailsDialog(QDialog):
|
|||||||
|
|
||||||
# Account for poll tab if it exists
|
# Account for poll tab if it exists
|
||||||
# Tab order: Poll (if exists), Content, Favorites, Boosts
|
# Tab order: Poll (if exists), Content, Favorites, Boosts
|
||||||
has_poll = hasattr(self.post, "poll") and self.post.poll
|
has_poll = self.post.has_poll()
|
||||||
favorites_tab_index = 2 if has_poll else 1
|
favorites_tab_index = 2 if has_poll else 1
|
||||||
boosts_tab_index = 3 if has_poll else 2
|
boosts_tab_index = 3 if has_poll else 2
|
||||||
|
|
||||||
@ -234,7 +235,15 @@ class PostDetailsDialog(QDialog):
|
|||||||
poll_widget = QWidget()
|
poll_widget = QWidget()
|
||||||
poll_layout = QVBoxLayout(poll_widget)
|
poll_layout = QVBoxLayout(poll_widget)
|
||||||
|
|
||||||
|
# Get poll from reblogged post if this is a boost
|
||||||
|
poll_data = None
|
||||||
|
if self.post.reblog and self.post.reblog.poll:
|
||||||
|
poll_data = self.post.reblog.poll
|
||||||
|
elif self.post.poll:
|
||||||
poll_data = self.post.poll
|
poll_data = self.post.poll
|
||||||
|
else:
|
||||||
|
# Should not happen since has_poll() was True
|
||||||
|
return poll_widget
|
||||||
|
|
||||||
# Poll question (if exists)
|
# Poll question (if exists)
|
||||||
if "question" in poll_data and poll_data["question"]:
|
if "question" in poll_data and poll_data["question"]:
|
||||||
@ -244,9 +253,30 @@ class PostDetailsDialog(QDialog):
|
|||||||
poll_layout.addWidget(question_label)
|
poll_layout.addWidget(question_label)
|
||||||
|
|
||||||
# Check if user can still vote
|
# Check if user can still vote
|
||||||
can_vote = not poll_data.get("voted", False) and not poll_data.get(
|
import logging
|
||||||
"expired", False
|
from datetime import datetime, timezone
|
||||||
)
|
logger = logging.getLogger('bifrost.poll_debug')
|
||||||
|
voted = poll_data.get("voted", False)
|
||||||
|
expired = poll_data.get("expired", False)
|
||||||
|
|
||||||
|
# Also check client-side if poll has expired based on expires_at time
|
||||||
|
expires_at = poll_data.get("expires_at")
|
||||||
|
if expires_at and not expired:
|
||||||
|
try:
|
||||||
|
# Parse the expiration time and check if it's past
|
||||||
|
from dateutil import parser
|
||||||
|
expiry_time = parser.parse(expires_at)
|
||||||
|
current_time = datetime.now(timezone.utc)
|
||||||
|
if current_time > expiry_time:
|
||||||
|
logger.debug(f"Poll expired client-side: {current_time} > {expiry_time}")
|
||||||
|
expired = True
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Could not parse expiration time {expires_at}: {e}")
|
||||||
|
|
||||||
|
can_vote = not voted and not expired
|
||||||
|
|
||||||
|
logger.debug(f"Poll voting status: voted={voted}, expired={expired}, can_vote={can_vote}")
|
||||||
|
logger.debug(f"Poll data keys: {poll_data.keys()}")
|
||||||
|
|
||||||
if can_vote:
|
if can_vote:
|
||||||
# Show interactive voting interface
|
# Show interactive voting interface
|
||||||
@ -297,6 +327,11 @@ class PostDetailsDialog(QDialog):
|
|||||||
own_votes = poll_data.get("own_votes", [])
|
own_votes = poll_data.get("own_votes", [])
|
||||||
|
|
||||||
for i, option in enumerate(options):
|
for i, option in enumerate(options):
|
||||||
|
# Handle both dict and object formats for poll options
|
||||||
|
if hasattr(option, 'title'):
|
||||||
|
option_title = option.title
|
||||||
|
vote_count = getattr(option, 'votes_count', 0)
|
||||||
|
else:
|
||||||
vote_count = option.get("votes_count", 0)
|
vote_count = option.get("votes_count", 0)
|
||||||
option_title = option.get("title", f"Option {i + 1}")
|
option_title = option.get("title", f"Option {i + 1}")
|
||||||
|
|
||||||
@ -331,19 +366,36 @@ class PostDetailsDialog(QDialog):
|
|||||||
options = poll_data.get("options", [])
|
options = poll_data.get("options", [])
|
||||||
|
|
||||||
for i, option in enumerate(options):
|
for i, option in enumerate(options):
|
||||||
|
# Debug logging for poll option structure
|
||||||
|
import logging
|
||||||
|
logger = logging.getLogger('bifrost.poll_debug')
|
||||||
|
logger.debug(f"Post details poll option {i}: {option}")
|
||||||
|
logger.debug(f"Post details poll option type: {type(option)}")
|
||||||
|
|
||||||
|
# Handle both dict and object formats for poll options
|
||||||
|
if hasattr(option, 'title'):
|
||||||
|
option_title = option.title
|
||||||
|
vote_count = getattr(option, 'votes_count', 0)
|
||||||
|
logger.debug(f"Post details using object format - title: {option_title}")
|
||||||
|
else:
|
||||||
vote_count = option.get("votes_count", 0)
|
vote_count = option.get("votes_count", 0)
|
||||||
option_title = option.get("title", f"Option {i + 1}")
|
option_title = option.get("title", f"Option {i + 1}")
|
||||||
|
logger.debug(f"Post details using dict format - title: {option_title}")
|
||||||
|
logger.debug(f"Post details available keys: {option.keys() if hasattr(option, 'keys') else 'Not a dict'}")
|
||||||
option_text = f"{option_title} ({vote_count} votes)"
|
option_text = f"{option_title} ({vote_count} votes)"
|
||||||
|
logger.debug(f"Creating widget with text: '{option_text}'")
|
||||||
|
|
||||||
if multiple_choice:
|
if multiple_choice:
|
||||||
# Multiple choice - use checkboxes
|
# Multiple choice - use checkboxes
|
||||||
option_widget = QCheckBox(option_text)
|
option_widget = QCheckBox(option_text)
|
||||||
|
logger.debug(f"Created checkbox with text: '{option_widget.text()}'")
|
||||||
else:
|
else:
|
||||||
# Single choice - use radio buttons
|
# Single choice - use radio buttons
|
||||||
option_widget = QRadioButton(option_text)
|
option_widget = QRadioButton(option_text)
|
||||||
|
logger.debug(f"Created radio button with text: '{option_widget.text()}'")
|
||||||
self.poll_button_group.addButton(option_widget, i)
|
self.poll_button_group.addButton(option_widget, i)
|
||||||
|
|
||||||
option_widget.setAccessibleName(f"Poll Option {i + 1}")
|
option_widget.setAccessibleName(option_text)
|
||||||
self.poll_option_widgets.append(option_widget)
|
self.poll_option_widgets.append(option_widget)
|
||||||
options_layout.addWidget(option_widget)
|
options_layout.addWidget(option_widget)
|
||||||
|
|
||||||
@ -435,8 +487,8 @@ class PostDetailsDialog(QDialog):
|
|||||||
# Emit vote signal for parent to handle
|
# Emit vote signal for parent to handle
|
||||||
self.vote_submitted.emit(self.post, selected_choices)
|
self.vote_submitted.emit(self.post, selected_choices)
|
||||||
|
|
||||||
# Close dialog after voting
|
# Dialog will be closed by the parent if vote succeeds
|
||||||
self.accept()
|
# If vote fails, the error signal will be emitted and dialog stays open
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error(f"Error submitting poll vote: {e}")
|
self.logger.error(f"Error submitting poll vote: {e}")
|
||||||
|
@ -29,7 +29,6 @@ from config.accounts import AccountManager
|
|||||||
from activitypub.client import ActivityPubClient
|
from activitypub.client import ActivityPubClient
|
||||||
from models.post import Post, Account
|
from models.post import Post, Account
|
||||||
from models.conversation import Conversation, PleromaChatConversation
|
from models.conversation import Conversation, PleromaChatConversation
|
||||||
from widgets.poll_voting_dialog import PollVotingDialog
|
|
||||||
from managers.post_actions_manager import PostActionsManager
|
from managers.post_actions_manager import PostActionsManager
|
||||||
|
|
||||||
|
|
||||||
@ -1663,33 +1662,29 @@ class TimelineView(QTreeWidget):
|
|||||||
# Call parent implementation for other keys
|
# Call parent implementation for other keys
|
||||||
super().keyPressEvent(event)
|
super().keyPressEvent(event)
|
||||||
|
|
||||||
def show_poll_voting_dialog(self, post):
|
def submit_poll_vote(self, post, choices: List[int], dialog=None):
|
||||||
"""Show poll voting dialog for a post"""
|
|
||||||
if not post.poll:
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Create and show poll voting dialog
|
|
||||||
dialog = PollVotingDialog(post.poll, self)
|
|
||||||
dialog.vote_submitted.connect(
|
|
||||||
lambda choices: self.submit_poll_vote(post, choices)
|
|
||||||
)
|
|
||||||
dialog.exec()
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f"Error showing poll dialog: {e}")
|
|
||||||
|
|
||||||
def submit_poll_vote(self, post, choices: List[int]):
|
|
||||||
"""Submit a vote in a poll"""
|
"""Submit a vote in a poll"""
|
||||||
if not self.activitypub_client or not post.poll:
|
if not self.activitypub_client or not post.has_poll():
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
# Get poll data from reblogged post if this is a boost
|
||||||
|
poll_data = None
|
||||||
|
if post.reblog and post.reblog.poll:
|
||||||
|
poll_data = post.reblog.poll
|
||||||
|
elif post.poll:
|
||||||
|
poll_data = post.poll
|
||||||
|
else:
|
||||||
|
return # No poll found
|
||||||
|
|
||||||
# Submit vote via API
|
# Submit vote via API
|
||||||
result = self.activitypub_client.vote_in_poll(post.poll["id"], choices)
|
result = self.activitypub_client.vote_in_poll(poll_data["id"], choices)
|
||||||
|
|
||||||
# Update local poll data with new results
|
# Update local poll data with new results
|
||||||
if "poll" in result:
|
if "poll" in result:
|
||||||
|
if post.reblog and post.reblog.poll:
|
||||||
|
post.reblog.poll = result["poll"]
|
||||||
|
else:
|
||||||
post.poll = result["poll"]
|
post.poll = result["poll"]
|
||||||
# Refresh the display to show updated results
|
# Refresh the display to show updated results
|
||||||
self.refresh_post_display(post)
|
self.refresh_post_display(post)
|
||||||
@ -1701,10 +1696,29 @@ class TimelineView(QTreeWidget):
|
|||||||
# and prevent duplicate voting attempts
|
# and prevent duplicate voting attempts
|
||||||
self.refresh(preserve_position=True)
|
self.refresh(preserve_position=True)
|
||||||
|
|
||||||
|
# Close dialog on successful vote
|
||||||
|
if dialog:
|
||||||
|
dialog.accept()
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error(f"Failed to submit poll vote: {e}")
|
self.logger.error(f"Failed to submit poll vote: {e}")
|
||||||
# Play error sound
|
# Play error sound
|
||||||
self.sound_manager.play_error()
|
self.sound_manager.play_error()
|
||||||
|
# Emit error signal if dialog provided
|
||||||
|
if dialog:
|
||||||
|
dialog.vote_error.emit(str(e))
|
||||||
|
|
||||||
|
def show_poll_error_dialog(self, error_message: str):
|
||||||
|
"""Show error dialog for poll voting failures"""
|
||||||
|
from PySide6.QtWidgets import QMessageBox
|
||||||
|
|
||||||
|
msg_box = QMessageBox(self)
|
||||||
|
msg_box.setIcon(QMessageBox.Warning)
|
||||||
|
msg_box.setWindowTitle("Poll Voting Error")
|
||||||
|
msg_box.setText("Failed to submit vote")
|
||||||
|
msg_box.setDetailedText(error_message)
|
||||||
|
msg_box.setStandardButtons(QMessageBox.Ok)
|
||||||
|
msg_box.exec()
|
||||||
|
|
||||||
def refresh_post_display(self, post):
|
def refresh_post_display(self, post):
|
||||||
"""Refresh the display of a specific post (for poll updates)"""
|
"""Refresh the display of a specific post (for poll updates)"""
|
||||||
@ -1944,10 +1958,11 @@ class TimelineView(QTreeWidget):
|
|||||||
dialog = PostDetailsDialog(
|
dialog = PostDetailsDialog(
|
||||||
post, self.activitypub_client, self.sound_manager, self
|
post, self.activitypub_client, self.sound_manager, self
|
||||||
)
|
)
|
||||||
# Connect poll voting signal
|
# Connect poll voting signals
|
||||||
dialog.vote_submitted.connect(
|
dialog.vote_submitted.connect(
|
||||||
lambda post, choices: self.submit_poll_vote(post, choices)
|
lambda post, choices: self.submit_poll_vote(post, choices, dialog)
|
||||||
)
|
)
|
||||||
|
dialog.vote_error.connect(self.show_poll_error_dialog)
|
||||||
dialog.exec()
|
dialog.exec()
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
Reference in New Issue
Block a user