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:
Storm Dragon
2025-07-24 17:37:02 -04:00
parent e226755e56
commit a9e26e1492
5 changed files with 830 additions and 266 deletions
+9 -3
View File
@@ -333,14 +333,20 @@ class Post:
def has_poll(self) -> bool:
"""Check if this post has a poll"""
if self.reblog:
return self.reblog.poll is not None
return self.poll is not None
def get_poll_info(self) -> str:
"""Get accessible poll information"""
if not self.poll:
# For boosted posts, get poll from the reblogged post
poll = None
if self.reblog and self.reblog.poll:
poll = self.reblog.poll
elif self.poll:
poll = self.poll
else:
return ""
poll = self.poll
options_count = len(poll.get('options', []))
expires_at = poll.get('expires_at')
multiple = poll.get('multiple', False)
-226
View File
@@ -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
+65 -13
View File
@@ -72,6 +72,7 @@ class PostDetailsDialog(QDialog):
vote_submitted = Signal(
object, list
) # Emitted with post and list of selected choice indices
vote_error = Signal(str) # Emitted when vote submission fails
def __init__(
self, post, client: ActivityPubClient, sound_manager: SoundManager, parent=None
@@ -104,7 +105,7 @@ class PostDetailsDialog(QDialog):
self.tabs.setAccessibleName("Interaction Details")
# 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()
poll_tab_index = self.tabs.addTab(self.poll_widget, "Poll")
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
# 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
boosts_tab_index = 3 if has_poll else 2
@@ -234,7 +235,15 @@ class PostDetailsDialog(QDialog):
poll_widget = QWidget()
poll_layout = QVBoxLayout(poll_widget)
poll_data = self.post.poll
# 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
else:
# Should not happen since has_poll() was True
return poll_widget
# Poll question (if exists)
if "question" in poll_data and poll_data["question"]:
@@ -244,9 +253,30 @@ class PostDetailsDialog(QDialog):
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
)
import logging
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:
# Show interactive voting interface
@@ -297,8 +327,13 @@ class PostDetailsDialog(QDialog):
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}")
# 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)
option_title = option.get("title", f"Option {i + 1}")
# Mark user's votes
vote_indicator = "" if i in own_votes else ""
@@ -331,19 +366,36 @@ class PostDetailsDialog(QDialog):
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}")
# 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)
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)"
logger.debug(f"Creating widget with text: '{option_text}'")
if multiple_choice:
# Multiple choice - use checkboxes
option_widget = QCheckBox(option_text)
logger.debug(f"Created checkbox with text: '{option_widget.text()}'")
else:
# Single choice - use radio buttons
option_widget = QRadioButton(option_text)
logger.debug(f"Created radio button with text: '{option_widget.text()}'")
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)
options_layout.addWidget(option_widget)
@@ -435,8 +487,8 @@ class PostDetailsDialog(QDialog):
# Emit vote signal for parent to handle
self.vote_submitted.emit(self.post, selected_choices)
# Close dialog after voting
self.accept()
# Dialog will be closed by the parent if vote succeeds
# If vote fails, the error signal will be emitted and dialog stays open
except Exception as e:
self.logger.error(f"Error submitting poll vote: {e}")
+38 -23
View File
@@ -29,7 +29,6 @@ from config.accounts import AccountManager
from activitypub.client import ActivityPubClient
from models.post import Post, Account
from models.conversation import Conversation, PleromaChatConversation
from widgets.poll_voting_dialog import PollVotingDialog
from managers.post_actions_manager import PostActionsManager
@@ -1663,34 +1662,30 @@ class TimelineView(QTreeWidget):
# Call parent implementation for other keys
super().keyPressEvent(event)
def show_poll_voting_dialog(self, post):
"""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]):
def submit_poll_vote(self, post, choices: List[int], dialog=None):
"""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
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
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
if "poll" in result:
post.poll = result["poll"]
if post.reblog and post.reblog.poll:
post.reblog.poll = result["poll"]
else:
post.poll = result["poll"]
# Refresh the display to show updated results
self.refresh_post_display(post)
@@ -1700,11 +1695,30 @@ class TimelineView(QTreeWidget):
# Refresh the entire timeline to ensure poll state is properly updated
# and prevent duplicate voting attempts
self.refresh(preserve_position=True)
# Close dialog on successful vote
if dialog:
dialog.accept()
except Exception as e:
self.logger.error(f"Failed to submit poll vote: {e}")
# Play error sound
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):
"""Refresh the display of a specific post (for poll updates)"""
@@ -1944,10 +1958,11 @@ class TimelineView(QTreeWidget):
dialog = PostDetailsDialog(
post, self.activitypub_client, self.sound_manager, self
)
# Connect poll voting signal
# Connect poll voting signals
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()
except Exception as e: