Add comprehensive user profile viewer with full accessibility support

- Create ProfileDialog with threaded loading and tabbed interface
- Display user bio, profile fields, stats, and recent posts
- Implement Follow/Unfollow, Block/Unblock, Mute/Unmute actions
- Add ActivityPub API methods for social actions and account management
- Enable keyboard navigation in read-only bio text with TextInteractionFlags
- Replace TODO profile viewing with fully functional dialog
- Update documentation to reflect completed profile features

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Storm Dragon
2025-07-21 10:39:07 -04:00
parent 1391d0c66f
commit 684919f4ca
6 changed files with 667 additions and 19 deletions

View File

@@ -423,25 +423,27 @@ verbose_announcements = true
- **Challenge**: Users want different audio feedback
- **Solution**: Comprehensive sound pack system with easy installation
## Planned Feature Additions (TODO)
## Recently Implemented Features
### High Priority Missing Features
- **Direct Message Interface**: Dedicated DM tab with conversation threading (separate from private posts)
- **Bookmarks Tab**: Timeline tab for viewing saved/bookmarked posts
- **User Blocking**: Block/unblock users with management interface
- **User Muting**: Mute/unmute users functionality
- **Poll Support**: Create and vote on polls with accessible interface
### 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
### Medium Priority Features
### 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
- **Poll Creation**: Add poll options to compose dialog
- **Poll Voting**: Accessible poll interaction ("Poll: What's your favorite color? 3 options, press Enter to vote")
### Implementation Notes
- Models already have bookmark, muted, blocking fields - just need API integration
- Timeline will need additional tabs: Home, Mentions, Local, Federated, DMs, Bookmarks
- Poll accessibility: Announce poll in timeline, Enter to interact, arrow keys to navigate options
- DM interface should show conversation threads rather than timeline format
### 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

View File

@@ -25,6 +25,8 @@ This project was created through "vibe coding" - a collaborative development app
- **Direct Message Interface**: Dedicated conversation view with threading support
- **Bookmarks**: Save and view bookmarked posts in a dedicated timeline
- **Poll Support**: Create, vote in, and view results of fediverse polls with full accessibility
- **User Profile Viewer**: Comprehensive profile viewing with bio, fields, recent posts, and social actions
- **Social Features**: Follow/unfollow, block/unblock, and mute/unmute users directly from profiles
## Audio System

View File

@@ -385,6 +385,65 @@ class ActivityPubClient:
endpoint = f'/api/v1/polls/{poll_id}/votes'
data = {'choices': choices}
return self._make_request('POST', endpoint, data=data)
def get_account_statuses(self, account_id: str, limit: int = 40,
max_id: Optional[str] = None, since_id: Optional[str] = None,
exclude_reblogs: bool = False, exclude_replies: bool = False,
only_media: bool = False, pinned: bool = False) -> List[Dict]:
"""Get account's statuses/posts"""
params = {'limit': limit}
if max_id:
params['max_id'] = max_id
if since_id:
params['since_id'] = since_id
if exclude_reblogs:
params['exclude_reblogs'] = 'true'
if exclude_replies:
params['exclude_replies'] = 'true'
if only_media:
params['only_media'] = 'true'
if pinned:
params['pinned'] = 'true'
endpoint = f'/api/v1/accounts/{account_id}/statuses'
return self._make_request('GET', endpoint, params=params)
def block_account(self, account_id: str) -> Dict:
"""Block an account"""
endpoint = f'/api/v1/accounts/{account_id}/block'
return self._make_request('POST', endpoint)
def unblock_account(self, account_id: str) -> Dict:
"""Unblock an account"""
endpoint = f'/api/v1/accounts/{account_id}/unblock'
return self._make_request('POST', endpoint)
def mute_account(self, account_id: str, notifications: bool = True) -> Dict:
"""Mute an account"""
endpoint = f'/api/v1/accounts/{account_id}/mute'
data = {'notifications': notifications}
return self._make_request('POST', endpoint, data=data)
def unmute_account(self, account_id: str) -> Dict:
"""Unmute an account"""
endpoint = f'/api/v1/accounts/{account_id}/unmute'
return self._make_request('POST', endpoint)
def get_blocked_accounts(self, limit: int = 40, max_id: Optional[str] = None) -> List[Dict]:
"""Get list of blocked accounts"""
params = {'limit': limit}
if max_id:
params['max_id'] = max_id
return self._make_request('GET', '/api/v1/blocks', params=params)
def get_muted_accounts(self, limit: int = 40, max_id: Optional[str] = None) -> List[Dict]:
"""Get list of muted accounts"""
params = {'limit': limit}
if max_id:
params['max_id'] = max_id
return self._make_request('GET', '/api/v1/mutes', params=params)
class AuthenticationError(Exception):

View File

@@ -18,6 +18,7 @@ from widgets.login_dialog import LoginDialog
from widgets.account_selector import AccountSelector
from widgets.settings_dialog import SettingsDialog
from widgets.soundpack_manager_dialog import SoundpackManagerDialog
from widgets.profile_dialog import ProfileDialog
from activitypub.client import ActivityPubClient
@@ -632,8 +633,49 @@ class MainWindow(QMainWindow):
def view_profile(self, post):
"""View user profile"""
# TODO: Implement profile viewing dialog
self.status_bar.showMessage(f"Profile viewing not implemented yet: {post.account.display_name}", 3000)
try:
# Convert Post.account to User-compatible data and open profile dialog
from models.user import User
# Create User object from Account data
account = post.account
account_data = {
'id': account.id,
'username': account.username,
'acct': account.acct,
'display_name': account.display_name,
'note': account.note,
'url': account.url,
'avatar': account.avatar,
'avatar_static': account.avatar_static,
'header': account.header,
'header_static': account.header_static,
'locked': account.locked,
'bot': account.bot,
'discoverable': account.discoverable,
'group': account.group,
'created_at': account.created_at.isoformat() if account.created_at else None,
'followers_count': account.followers_count,
'following_count': account.following_count,
'statuses_count': account.statuses_count,
'fields': [], # Will be loaded from API
'emojis': [] # Will be loaded from API
}
user = User.from_api_dict(account_data)
dialog = ProfileDialog(
user_id=user.id,
account_manager=self.account_manager,
sound_manager=self.timeline.sound_manager,
initial_user=user,
parent=self
)
dialog.exec()
except Exception as e:
self.status_bar.showMessage(f"Error opening profile: {str(e)}", 3000)
if hasattr(self.timeline, 'sound_manager'):
self.timeline.sound_manager.play_error()
def update_status_label(self):
"""Update the status label with current account info"""

View File

@@ -234,6 +234,5 @@ class User:
'requested': self.requested,
'domain_blocking': self.domain_blocking,
'showing_reblogs': self.showing_reblogs,
'endorsed': self.endorsed,
'note_plain': self.note_plain
'endorsed': self.endorsed
}

View File

@@ -0,0 +1,544 @@
"""
User profile dialog for viewing fediverse user profiles
"""
from PySide6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
QTextEdit, QTabWidget, QListWidget, QListWidgetItem,
QDialogButtonBox, QScrollArea, QWidget, QFormLayout,
QMessageBox, QGroupBox
)
from PySide6.QtCore import Qt, Signal, QThread, QTimer
from PySide6.QtGui import QFont
from typing import Optional, List, Dict, Any
import html
from models.user import User, Field
from models.post import Post
from activitypub.client import ActivityPubClient
from config.accounts import AccountManager
from audio.sound_manager import SoundManager
class ProfileLoadThread(QThread):
"""Thread for loading profile data without blocking UI"""
profile_loaded = Signal(object) # User object
relationship_loaded = Signal(dict) # Relationship dict
posts_loaded = Signal(list) # List of recent posts
error_occurred = Signal(str) # Error message
def __init__(self, client: ActivityPubClient, user_id: str):
super().__init__()
self.client = client
self.user_id = user_id
def run(self):
try:
# Load profile data
profile_data = self.client.get_account(self.user_id)
user = User.from_api_dict(profile_data)
self.profile_loaded.emit(user)
# Load relationship
relationship_data = self.client.get_relationship(self.user_id)
self.relationship_loaded.emit(relationship_data)
# Load recent posts
posts_data = self.client.get_account_statuses(self.user_id, limit=10)
posts = [Post.from_api_dict(post_data) for post_data in posts_data]
self.posts_loaded.emit(posts)
except Exception as e:
self.error_occurred.emit(str(e))
class ProfileDialog(QDialog):
"""Dialog for displaying user profiles with accessibility focus"""
def __init__(self, user_id: str, account_manager: AccountManager,
sound_manager: SoundManager, initial_user: Optional[User] = None,
parent=None):
super().__init__(parent)
self.user_id = user_id
self.account_manager = account_manager
self.sound_manager = sound_manager
self.user = initial_user # May be None, will load from API
self.relationship = {}
self.recent_posts = []
# Get API client
active_account = self.account_manager.get_active_account()
if not active_account:
raise ValueError("No active account for profile viewing")
self.client = ActivityPubClient(
active_account.instance_url,
active_account.access_token
)
self.setup_ui()
self.load_profile_data()
def setup_ui(self):
"""Initialize the user interface"""
self.setWindowTitle("User Profile")
self.setMinimumSize(600, 500)
self.setModal(True)
# Main layout
main_layout = QVBoxLayout(self)
# Loading label
self.loading_label = QLabel("Loading profile...")
self.loading_label.setAccessibleName("Profile Loading Status")
self.loading_label.setAlignment(Qt.AlignCenter)
main_layout.addWidget(self.loading_label)
# Content widget (initially hidden)
self.content_widget = QWidget()
self.content_widget.hide()
self.setup_content_ui()
main_layout.addWidget(self.content_widget)
# Button box
self.button_box = QDialogButtonBox(QDialogButtonBox.Close)
self.button_box.rejected.connect(self.reject)
main_layout.addWidget(self.button_box)
def setup_content_ui(self):
"""Setup the main content UI (shown after loading)"""
content_layout = QVBoxLayout(self.content_widget)
# User header section
self.setup_header_section(content_layout)
# Tab widget for different views
self.tab_widget = QTabWidget()
self.tab_widget.setAccessibleName("Profile Information Tabs")
# Profile tab
self.setup_profile_tab()
self.tab_widget.addTab(self.profile_tab, "Profile")
# Recent posts tab
self.setup_posts_tab()
self.tab_widget.addTab(self.posts_tab, "Recent Posts")
content_layout.addWidget(self.tab_widget)
def setup_header_section(self, parent_layout):
"""Setup the profile header with name, stats, and action buttons"""
header_widget = QWidget()
header_layout = QVBoxLayout(header_widget)
# User name and handle
self.name_label = QLabel()
self.name_label.setAccessibleName("User Display Name")
name_font = QFont()
name_font.setPointSize(14)
name_font.setBold(True)
self.name_label.setFont(name_font)
header_layout.addWidget(self.name_label)
self.handle_label = QLabel()
self.handle_label.setAccessibleName("User Handle")
header_layout.addWidget(self.handle_label)
# Stats row
stats_layout = QHBoxLayout()
self.posts_stat = QLabel()
self.posts_stat.setAccessibleName("Post Count")
stats_layout.addWidget(self.posts_stat)
self.following_stat = QLabel()
self.following_stat.setAccessibleName("Following Count")
stats_layout.addWidget(self.following_stat)
self.followers_stat = QLabel()
self.followers_stat.setAccessibleName("Followers Count")
stats_layout.addWidget(self.followers_stat)
stats_layout.addStretch()
header_layout.addLayout(stats_layout)
# Action buttons
actions_layout = QHBoxLayout()
self.follow_button = QPushButton()
self.follow_button.setAccessibleName("Follow or Unfollow User")
self.follow_button.clicked.connect(self.toggle_follow)
actions_layout.addWidget(self.follow_button)
self.block_button = QPushButton()
self.block_button.setAccessibleName("Block or Unblock User")
self.block_button.clicked.connect(self.toggle_block)
actions_layout.addWidget(self.block_button)
self.mute_button = QPushButton()
self.mute_button.setAccessibleName("Mute or Unmute User")
self.mute_button.clicked.connect(self.toggle_mute)
actions_layout.addWidget(self.mute_button)
actions_layout.addStretch()
header_layout.addLayout(actions_layout)
parent_layout.addWidget(header_widget)
def setup_profile_tab(self):
"""Setup the profile information tab"""
self.profile_tab = QScrollArea()
self.profile_tab.setAccessibleName("Profile Information")
self.profile_tab.setWidgetResizable(True)
profile_content = QWidget()
profile_layout = QVBoxLayout(profile_content)
# Bio section
bio_group = QGroupBox("Biography")
bio_group.setAccessibleName("User Biography Section")
bio_layout = QVBoxLayout(bio_group)
self.bio_text = QTextEdit()
self.bio_text.setAccessibleName("User Biography")
self.bio_text.setReadOnly(True)
self.bio_text.setTextInteractionFlags(Qt.TextSelectableByMouse | Qt.TextSelectableByKeyboard | Qt.LinksAccessibleByMouse | Qt.LinksAccessibleByKeyboard)
self.bio_text.setMaximumHeight(100)
bio_layout.addWidget(self.bio_text)
profile_layout.addWidget(bio_group)
# Profile fields section
self.fields_group = QGroupBox("Profile Fields")
self.fields_group.setAccessibleName("Profile Fields Section")
self.fields_layout = QFormLayout(self.fields_group)
profile_layout.addWidget(self.fields_group)
# Account info section
info_group = QGroupBox("Account Information")
info_group.setAccessibleName("Account Information Section")
info_layout = QFormLayout(info_group)
self.joined_label = QLabel()
self.joined_label.setAccessibleName("Account Creation Date")
info_layout.addRow("Joined:", self.joined_label)
self.last_active_label = QLabel()
self.last_active_label.setAccessibleName("Last Activity Date")
info_layout.addRow("Last Active:", self.last_active_label)
self.account_type_label = QLabel()
self.account_type_label.setAccessibleName("Account Type")
info_layout.addRow("Account Type:", self.account_type_label)
profile_layout.addWidget(info_group)
profile_layout.addStretch()
self.profile_tab.setWidget(profile_content)
def setup_posts_tab(self):
"""Setup the recent posts tab"""
self.posts_tab = QWidget()
posts_layout = QVBoxLayout(self.posts_tab)
posts_label = QLabel("Recent Posts")
posts_label.setAccessibleName("Recent Posts Section")
font = QFont()
font.setBold(True)
posts_label.setFont(font)
posts_layout.addWidget(posts_label)
self.posts_list = QListWidget()
self.posts_list.setAccessibleName("Recent Posts List")
posts_layout.addWidget(self.posts_list)
def load_profile_data(self):
"""Load profile data in background thread"""
if self.user:
# We already have basic user data, just load relationship and posts
self.display_user_data()
# Always load fresh data from API
self.load_thread = ProfileLoadThread(self.client, self.user_id)
self.load_thread.profile_loaded.connect(self.on_profile_loaded)
self.load_thread.relationship_loaded.connect(self.on_relationship_loaded)
self.load_thread.posts_loaded.connect(self.on_posts_loaded)
self.load_thread.error_occurred.connect(self.on_load_error)
self.load_thread.start()
def on_profile_loaded(self, user: User):
"""Handle profile data being loaded"""
self.user = user
self.display_user_data()
def on_relationship_loaded(self, relationship_data: dict):
"""Handle relationship data being loaded"""
self.relationship = relationship_data
self.update_action_buttons()
def on_posts_loaded(self, posts: List[Post]):
"""Handle recent posts being loaded"""
self.recent_posts = posts
self.display_recent_posts()
# Hide loading label and show content
self.loading_label.hide()
self.content_widget.show()
# Play success sound
self.sound_manager.play_success()
def on_load_error(self, error_message: str):
"""Handle loading error"""
self.loading_label.setText(f"Error loading profile: {error_message}")
self.sound_manager.play_error()
def display_user_data(self):
"""Display the loaded user data"""
if not self.user:
return
# Header information
display_name = self.user.get_display_name()
self.name_label.setText(display_name)
full_username = f"@{self.user.get_full_username()}"
self.handle_label.setText(full_username)
# Stats
self.posts_stat.setText(f"{self.user.statuses_count:,} posts")
self.following_stat.setText(f"{self.user.following_count:,} following")
self.followers_stat.setText(f"{self.user.followers_count:,} followers")
# Biography
bio_html = self.user.note if self.user.note else "No biography provided."
self.bio_text.setHtml(bio_html)
# Profile fields
self.display_profile_fields()
# Account information
if self.user.created_at:
joined_text = self.user.created_at.strftime("%B %d, %Y")
self.joined_label.setText(joined_text)
else:
self.joined_label.setText("Unknown")
if self.user.last_status_at:
last_active_text = self.user.last_status_at.strftime("%B %d, %Y")
self.last_active_label.setText(last_active_text)
else:
self.last_active_label.setText("Unknown")
# Account type
account_type_parts = []
if self.user.bot:
account_type_parts.append("Bot")
if self.user.group:
account_type_parts.append("Group")
if self.user.locked:
account_type_parts.append("Private")
if account_type_parts:
account_type = ", ".join(account_type_parts)
else:
account_type = "Public User"
self.account_type_label.setText(account_type)
# Update window title
self.setWindowTitle(f"Profile: {display_name}")
def display_profile_fields(self):
"""Display user profile fields"""
# Clear existing fields
while self.fields_layout.rowCount() > 0:
self.fields_layout.removeRow(0)
if not self.user.fields:
no_fields_label = QLabel("No profile fields")
no_fields_label.setAccessibleName("No Profile Fields Message")
self.fields_layout.addRow(no_fields_label)
return
for field in self.user.fields:
field_name = html.unescape(field.name)
field_value = html.unescape(field.value)
# Create value label
value_label = QLabel(field_value)
value_label.setAccessibleName(f"Profile Field: {field_name}")
value_label.setWordWrap(True)
value_label.setTextFormat(Qt.RichText)
value_label.setOpenExternalLinks(True)
# Add verification indicator if field is verified
if field.verified_at:
field_name += ""
value_label.setStyleSheet("QLabel { color: green; }")
self.fields_layout.addRow(f"{field_name}:", value_label)
def display_recent_posts(self):
"""Display recent posts"""
self.posts_list.clear()
if not self.recent_posts:
no_posts_item = QListWidgetItem("No recent posts")
no_posts_item.setData(Qt.AccessibleTextRole, "No recent posts available")
self.posts_list.addItem(no_posts_item)
return
for post in self.recent_posts:
# Create post summary for list
content_text = post.get_content_text()
if len(content_text) > 100:
content_text = content_text[:100] + "..."
created_date = post.created_at.strftime("%m/%d %H:%M") if post.created_at else "Unknown"
item_text = f"[{created_date}] {content_text}"
item = QListWidgetItem(item_text)
item.setData(Qt.UserRole, post)
# Accessibility description
accessibility_text = f"Post from {created_date}: {post.get_content_text()}"
item.setData(Qt.AccessibleTextRole, accessibility_text)
self.posts_list.addItem(item)
def update_action_buttons(self):
"""Update action buttons based on relationship status"""
if not self.relationship:
# No relationship data yet, disable buttons
self.follow_button.setEnabled(False)
self.block_button.setEnabled(False)
self.mute_button.setEnabled(False)
return
# Follow/Unfollow button
if self.relationship.get('following', False):
self.follow_button.setText("Unfollow")
self.follow_button.setAccessibleName("Unfollow this user")
elif self.relationship.get('requested', False):
self.follow_button.setText("Cancel Request")
self.follow_button.setAccessibleName("Cancel follow request")
else:
self.follow_button.setText("Follow")
self.follow_button.setAccessibleName("Follow this user")
self.follow_button.setEnabled(True)
# Block/Unblock button
if self.relationship.get('blocking', False):
self.block_button.setText("Unblock")
self.block_button.setAccessibleName("Unblock this user")
else:
self.block_button.setText("Block")
self.block_button.setAccessibleName("Block this user")
self.block_button.setEnabled(True)
# Mute/Unmute button
if self.relationship.get('muting', False):
self.mute_button.setText("Unmute")
self.mute_button.setAccessibleName("Unmute this user")
else:
self.mute_button.setText("Mute")
self.mute_button.setAccessibleName("Mute this user")
self.mute_button.setEnabled(True)
def toggle_follow(self):
"""Toggle follow status for this user"""
if not self.relationship:
return
try:
if self.relationship.get('following', False):
# Unfollow
self.client.unfollow_account(self.user_id)
self.relationship['following'] = False
self.relationship['requested'] = False
self.sound_manager.play_unfollow()
elif self.relationship.get('requested', False):
# Cancel follow request
self.client.unfollow_account(self.user_id)
self.relationship['requested'] = False
self.sound_manager.play_success()
else:
# Follow
result = self.client.follow_account(self.user_id)
if result.get('following', False):
self.relationship['following'] = True
else:
self.relationship['requested'] = True
self.sound_manager.play_follow()
self.update_action_buttons()
except Exception as e:
QMessageBox.warning(self, "Follow Error", f"Failed to update follow status: {e}")
self.sound_manager.play_error()
def toggle_block(self):
"""Toggle block status for this user"""
if not self.relationship:
return
try:
if self.relationship.get('blocking', False):
# Unblock
self.client.unblock_account(self.user_id)
self.relationship['blocking'] = False
self.sound_manager.play_success()
else:
# Block
result = QMessageBox.question(
self,
"Block User",
f"Are you sure you want to block @{self.user.get_full_username()}?\n\n"
"This will prevent them from following you and seeing your posts.",
QMessageBox.Yes | QMessageBox.No,
QMessageBox.No
)
if result == QMessageBox.Yes:
self.client.block_account(self.user_id)
self.relationship['blocking'] = True
self.relationship['following'] = False
self.relationship['followed_by'] = False
self.sound_manager.play_success()
self.update_action_buttons()
except Exception as e:
QMessageBox.warning(self, "Block Error", f"Failed to update block status: {e}")
self.sound_manager.play_error()
def toggle_mute(self):
"""Toggle mute status for this user"""
if not self.relationship:
return
try:
if self.relationship.get('muting', False):
# Unmute
self.client.unmute_account(self.user_id)
self.relationship['muting'] = False
self.sound_manager.play_success()
else:
# Mute
self.client.mute_account(self.user_id)
self.relationship['muting'] = True
self.sound_manager.play_success()
self.update_action_buttons()
except Exception as e:
QMessageBox.warning(self, "Mute Error", f"Failed to update mute status: {e}")
self.sound_manager.play_error()