From 3d9ce28334f98d600752fcd865b0ade95ebf1a6a Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Mon, 21 Jul 2025 11:15:58 -0400 Subject: [PATCH] Add blocked/muted user management tabs with complete keyboard navigation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add Blocked Users and Muted Users timeline tabs with visual indicators (🚫/🔇) - Implement dedicated context menus for unblocking/unmuting from management tabs - Add complete keyboard shortcuts for all 10 timeline tabs (Ctrl+1 through Ctrl+0) - Add block (Ctrl+Shift+B) and mute (Ctrl+Shift+M) keyboard shortcuts with confirmation - Fix timeline tab indices and add missing shortcuts for all social timelines - Handle blocked/muted account display with accessibility-friendly status announcements - Prevent self-blocking/muting with appropriate error messages and sound feedback - Support immediate removal from lists after unblock/unmute actions 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/main_window.py | 155 +++++++++++++++++++++++++++++++++-- src/widgets/timeline_view.py | 118 +++++++++++++++++++++++--- 2 files changed, 253 insertions(+), 20 deletions(-) diff --git a/src/main_window.py b/src/main_window.py index d333ab4..6bcc5ed 100644 --- a/src/main_window.py +++ b/src/main_window.py @@ -77,6 +77,8 @@ class MainWindow(QMainWindow): self.timeline_tabs.addTab(QWidget(), "Bookmarks") self.timeline_tabs.addTab(QWidget(), "Followers") self.timeline_tabs.addTab(QWidget(), "Following") + self.timeline_tabs.addTab(QWidget(), "Blocked") + self.timeline_tabs.addTab(QWidget(), "Muted") self.timeline_tabs.currentChanged.connect(self.on_timeline_tab_changed) main_layout.addWidget(self.timeline_tabs) @@ -174,24 +176,60 @@ class MainWindow(QMainWindow): home_action.triggered.connect(lambda: self.switch_timeline(0)) timeline_menu.addAction(home_action) + # Messages timeline action + messages_action = QAction("&Messages", self) + messages_action.setShortcut(QKeySequence("Ctrl+2")) + messages_action.triggered.connect(lambda: self.switch_timeline(1)) + timeline_menu.addAction(messages_action) + # Mentions timeline action - mentions_action = QAction("&Mentions", self) - mentions_action.setShortcut(QKeySequence("Ctrl+2")) - mentions_action.triggered.connect(lambda: self.switch_timeline(1)) + mentions_action = QAction("M&entions", self) + mentions_action.setShortcut(QKeySequence("Ctrl+3")) + mentions_action.triggered.connect(lambda: self.switch_timeline(2)) timeline_menu.addAction(mentions_action) # Local timeline action local_action = QAction("&Local", self) - local_action.setShortcut(QKeySequence("Ctrl+3")) - local_action.triggered.connect(lambda: self.switch_timeline(2)) + local_action.setShortcut(QKeySequence("Ctrl+4")) + local_action.triggered.connect(lambda: self.switch_timeline(3)) timeline_menu.addAction(local_action) # Federated timeline action federated_action = QAction("&Federated", self) - federated_action.setShortcut(QKeySequence("Ctrl+4")) - federated_action.triggered.connect(lambda: self.switch_timeline(3)) + federated_action.setShortcut(QKeySequence("Ctrl+5")) + federated_action.triggered.connect(lambda: self.switch_timeline(4)) timeline_menu.addAction(federated_action) + # Bookmarks timeline action + bookmarks_action = QAction("&Bookmarks", self) + bookmarks_action.setShortcut(QKeySequence("Ctrl+6")) + bookmarks_action.triggered.connect(lambda: self.switch_timeline(5)) + timeline_menu.addAction(bookmarks_action) + + # Followers timeline action + followers_action = QAction("Follo&wers", self) + followers_action.setShortcut(QKeySequence("Ctrl+7")) + followers_action.triggered.connect(lambda: self.switch_timeline(6)) + timeline_menu.addAction(followers_action) + + # Following timeline action + following_action = QAction("Follo&wing", self) + following_action.setShortcut(QKeySequence("Ctrl+8")) + following_action.triggered.connect(lambda: self.switch_timeline(7)) + timeline_menu.addAction(following_action) + + # Blocked users timeline action + blocked_action = QAction("Bloc&ked Users", self) + blocked_action.setShortcut(QKeySequence("Ctrl+9")) + blocked_action.triggered.connect(lambda: self.switch_timeline(8)) + timeline_menu.addAction(blocked_action) + + # Muted users timeline action + muted_action = QAction("M&uted Users", self) + muted_action.setShortcut(QKeySequence("Ctrl+0")) + muted_action.triggered.connect(lambda: self.switch_timeline(9)) + timeline_menu.addAction(muted_action) + # Post menu post_menu = menubar.addMenu("&Post") @@ -258,6 +296,20 @@ class MainWindow(QMainWindow): social_menu.addSeparator() + # Block action + block_action = QAction("&Block User", self) + block_action.setShortcut(QKeySequence("Ctrl+Shift+B")) + block_action.triggered.connect(self.block_current_user) + social_menu.addAction(block_action) + + # Mute action + mute_action = QAction("&Mute User", self) + mute_action.setShortcut(QKeySequence("Ctrl+Shift+M")) + mute_action.triggered.connect(self.mute_current_user) + social_menu.addAction(mute_action) + + social_menu.addSeparator() + # Manual follow action manual_follow_action = QAction("Follow &Specific User...", self) manual_follow_action.setShortcut(QKeySequence("Ctrl+Shift+M")) @@ -451,8 +503,8 @@ class MainWindow(QMainWindow): def switch_timeline(self, index, from_tab_change=False): """Switch to timeline by index with loading feedback""" - timeline_names = ["Home", "Messages", "Mentions", "Local", "Federated", "Bookmarks", "Followers", "Following"] - timeline_types = ["home", "conversations", "notifications", "local", "federated", "bookmarks", "followers", "following"] + timeline_names = ["Home", "Messages", "Mentions", "Local", "Federated", "Bookmarks", "Followers", "Following", "Blocked", "Muted"] + timeline_types = ["home", "conversations", "notifications", "local", "federated", "bookmarks", "followers", "following", "blocked", "muted"] if 0 <= index < len(timeline_names): timeline_name = timeline_names[index] @@ -929,6 +981,91 @@ class MainWindow(QMainWindow): except Exception as e: self.status_bar.showMessage(f"Follow failed: {str(e)}", 3000) + def block_current_user(self): + """Block the user of the currently selected post""" + post = self.get_selected_post() + if post: + self.block_user(post) + else: + self.status_bar.showMessage("No post selected", 2000) + + def mute_current_user(self): + """Mute the user of the currently selected post""" + post = self.get_selected_post() + if post: + self.mute_user(post) + else: + self.status_bar.showMessage("No post selected", 2000) + + def block_user(self, post): + """Block a user with confirmation dialog""" + active_account = self.account_manager.get_active_account() + if not active_account or not hasattr(post, 'account'): + self.status_bar.showMessage("Cannot block: No active account", 2000) + return + + # Don't allow blocking yourself + is_own_post = (post.account.username == active_account.username and + post.account.acct.split('@')[-1] == active_account.instance_url.replace('https://', '').replace('http://', '')) + + if is_own_post: + self.status_bar.showMessage("Cannot block: Cannot block yourself", 2000) + return + + # Show confirmation dialog + username = post.account.display_name or post.account.username + full_username = f"@{post.account.acct}" + + result = QMessageBox.question( + self, + "Block User", + f"Are you sure you want to block {username} ({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: + try: + client = ActivityPubClient(active_account.instance_url, active_account.access_token) + client.block_account(post.account.id) + self.status_bar.showMessage(f"Blocked {username}", 2000) + # Play success sound for successful block + if hasattr(self.timeline, 'sound_manager'): + self.timeline.sound_manager.play_success() + except Exception as e: + self.status_bar.showMessage(f"Block failed: {str(e)}", 3000) + if hasattr(self.timeline, 'sound_manager'): + self.timeline.sound_manager.play_error() + + def mute_user(self, post): + """Mute a user""" + active_account = self.account_manager.get_active_account() + if not active_account or not hasattr(post, 'account'): + self.status_bar.showMessage("Cannot mute: No active account", 2000) + return + + # Don't allow muting yourself + is_own_post = (post.account.username == active_account.username and + post.account.acct.split('@')[-1] == active_account.instance_url.replace('https://', '').replace('http://', '')) + + if is_own_post: + self.status_bar.showMessage("Cannot mute: Cannot mute yourself", 2000) + return + + try: + client = ActivityPubClient(active_account.instance_url, active_account.access_token) + client.mute_account(post.account.id) + username = post.account.display_name or post.account.username + self.status_bar.showMessage(f"Muted {username}", 2000) + # Play success sound for successful mute + if hasattr(self.timeline, 'sound_manager'): + self.timeline.sound_manager.play_success() + except Exception as e: + self.status_bar.showMessage(f"Mute failed: {str(e)}", 3000) + if hasattr(self.timeline, 'sound_manager'): + self.timeline.sound_manager.play_error() + def closeEvent(self, event): """Handle window close event""" # Only play shutdown sound if not already played through quit_application diff --git a/src/widgets/timeline_view.py b/src/widgets/timeline_view.py index 1971ce5..e28be03 100644 --- a/src/widgets/timeline_view.py +++ b/src/widgets/timeline_view.py @@ -112,7 +112,7 @@ class TimelineView(AccessibleTreeWidget): # Get posts per page from settings posts_per_page = int(self.settings.get('timeline', 'posts_per_page', 40) or 40) - # Fetch timeline, notifications, followers/following, conversations, or bookmarks + # Fetch timeline, notifications, followers/following, conversations, bookmarks, blocked/muted users if self.timeline_type == "notifications": timeline_data = self.activitypub_client.get_notifications(limit=posts_per_page) elif self.timeline_type == "followers": @@ -127,6 +127,10 @@ class TimelineView(AccessibleTreeWidget): timeline_data = self.load_conversations(posts_per_page) elif self.timeline_type == "bookmarks": timeline_data = self.activitypub_client.get_bookmarks(limit=posts_per_page) + elif self.timeline_type == "blocked": + timeline_data = self.activitypub_client.get_blocked_accounts(limit=posts_per_page) + elif self.timeline_type == "muted": + timeline_data = self.activitypub_client.get_muted_accounts(limit=posts_per_page) else: timeline_data = self.activitypub_client.get_timeline(self.timeline_type, limit=posts_per_page) self.load_timeline_data(timeline_data) @@ -147,7 +151,7 @@ class TimelineView(AccessibleTreeWidget): """Load real timeline data from ActivityPub API""" # Check for new content by comparing newest post ID (only for regular timelines) has_new_content = False - if timeline_data and self.newest_post_id and self.timeline_type not in ["followers", "following"]: + if timeline_data and self.newest_post_id and self.timeline_type not in ["followers", "following", "blocked", "muted"]: # Check if the first post (newest) is different from what we had current_newest_id = timeline_data[0]['id'] if current_newest_id != self.newest_post_id: @@ -236,8 +240,8 @@ class TimelineView(AccessibleTreeWidget): except Exception as e: print(f"Error parsing conversation: {e}") continue - elif self.timeline_type in ["followers", "following"]: - # Handle followers/following data structure (account list) + elif self.timeline_type in ["followers", "following", "blocked", "muted"]: + # Handle followers/following/blocked/muted data structure (account list) for account_data in timeline_data: try: # Create a pseudo-post from account data for display @@ -246,27 +250,47 @@ class TimelineView(AccessibleTreeWidget): # Create a special Post-like object for accounts class AccountDisplayPost: - def __init__(self, user): + def __init__(self, user, account_type): self.id = user.id self.account = user + self.account_type = account_type # "followers", "following", "blocked", "muted" self.in_reply_to_id = None - self.content = f"

@{user.username} - {user.display_name or user.username}

" + + # Add status indicator based on account type + status_indicator = "" + if account_type == "blocked": + status_indicator = " 🚫" + elif account_type == "muted": + status_indicator = " 🔇" + + self.content = f"

@{user.username} - {user.display_name or user.username}{status_indicator}

" if user.note: self.content += f"
{user.note}" def get_content_text(self): - return f"@{self.account.username} - {self.account.display_name or self.account.username}" + status_text = "" + if self.account_type == "blocked": + status_text = " (Blocked)" + elif self.account_type == "muted": + status_text = " (Muted)" + return f"@{self.account.username} - {self.account.display_name or self.account.username}{status_text}" def get_summary_for_screen_reader(self): username = self.account.display_name or self.account.username + status_text = "" + if self.account_type == "blocked": + status_text = " - Blocked user" + elif self.account_type == "muted": + status_text = " - Muted user" + if self.account.note: # Strip HTML tags from note import re note = re.sub('<[^<]+?>', '', self.account.note) - return f"{username} (@{self.account.username}): {note}" - return f"{username} (@{self.account.username})" + return f"{username} (@{self.account.username}){status_text}: {note}" + return f"{username} (@{self.account.username}){status_text}" - account_post = AccountDisplayPost(user) + account_post = AccountDisplayPost(user, self.timeline_type) self.posts.append(account_post) except Exception as e: @@ -745,6 +769,32 @@ class TimelineView(AccessibleTreeWidget): menu = QMenu(self) + # Check if this is a blocked/muted user management context + is_blocked_user = (self.timeline_type == "blocked" and hasattr(post, 'account_type') and post.account_type == "blocked") + is_muted_user = (self.timeline_type == "muted" and hasattr(post, 'account_type') and post.account_type == "muted") + + # For blocked/muted user management, show simplified context menu + if is_blocked_user or is_muted_user: + # Unblock/Unmute action + if is_blocked_user: + unblock_action = QAction("&Unblock User", self) + unblock_action.triggered.connect(lambda: self.unblock_user_from_list(post)) + menu.addAction(unblock_action) + else: # is_muted_user + unmute_action = QAction("&Unmute User", self) + unmute_action.triggered.connect(lambda: self.unmute_user_from_list(post)) + menu.addAction(unmute_action) + + menu.addSeparator() + + # View profile action + profile_action = QAction("View &Profile", self) + profile_action.triggered.connect(lambda: self.profile_requested.emit(post)) + menu.addAction(profile_action) + + menu.exec(self.mapToGlobal(position)) + return + # Get current user account for ownership checks active_account = self.account_manager.get_active_account() is_own_post = False @@ -1035,4 +1085,50 @@ class TimelineView(AccessibleTreeWidget): def announce_current_item(self): """Announce the current item for screen readers""" # This will be handled by the AccessibleTreeWidget - pass \ No newline at end of file + pass + + def unblock_user_from_list(self, post): + """Unblock a user from the blocked users list""" + try: + # Unblock the user via API + self.activitypub_client.unblock_account(post.account.id) + + # Remove from current timeline display + current_item = self.currentItem() + if current_item and current_item.data(0, Qt.UserRole) == post: + # Find and remove the item + for i in range(self.topLevelItemCount()): + item = self.topLevelItem(i) + if item.data(0, Qt.UserRole) == post: + self.takeTopLevelItem(i) + break + + # Play success sound + self.sound_manager.play_success() + + except Exception as e: + print(f"Error unblocking user: {e}") + self.sound_manager.play_error() + + def unmute_user_from_list(self, post): + """Unmute a user from the muted users list""" + try: + # Unmute the user via API + self.activitypub_client.unmute_account(post.account.id) + + # Remove from current timeline display + current_item = self.currentItem() + if current_item and current_item.data(0, Qt.UserRole) == post: + # Find and remove the item + for i in range(self.topLevelItemCount()): + item = self.topLevelItem(i) + if item.data(0, Qt.UserRole) == post: + self.takeTopLevelItem(i) + break + + # Play success sound + self.sound_manager.play_success() + + except Exception as e: + print(f"Error unmuting user: {e}") + self.sound_manager.play_error() \ No newline at end of file