diff --git a/CLAUDE.md b/CLAUDE.md index 4b8c214..9d25ff4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/README.md b/README.md index d2a814b..3b52760 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/activitypub/client.py b/src/activitypub/client.py index 375f446..c3accca 100644 --- a/src/activitypub/client.py +++ b/src/activitypub/client.py @@ -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): diff --git a/src/main_window.py b/src/main_window.py index dd12a7b..d333ab4 100644 --- a/src/main_window.py +++ b/src/main_window.py @@ -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""" diff --git a/src/models/user.py b/src/models/user.py index 1b7f3ec..bea1b4a 100644 --- a/src/models/user.py +++ b/src/models/user.py @@ -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 } \ No newline at end of file diff --git a/src/widgets/profile_dialog.py b/src/widgets/profile_dialog.py new file mode 100644 index 0000000..6429484 --- /dev/null +++ b/src/widgets/profile_dialog.py @@ -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() \ No newline at end of file