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:
+9
-3
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
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}")
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user