Add blocked/muted user management tabs with complete keyboard navigation

- 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 <noreply@anthropic.com>
This commit is contained in:
Storm Dragon
2025-07-21 11:15:58 -04:00
parent 684919f4ca
commit 3d9ce28334
2 changed files with 253 additions and 20 deletions

View File

@ -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

View File

@ -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"<p>@{user.username} - {user.display_name or user.username}</p>"
# Add status indicator based on account type
status_indicator = ""
if account_type == "blocked":
status_indicator = " 🚫"
elif account_type == "muted":
status_indicator = " 🔇"
self.content = f"<p>@{user.username} - {user.display_name or user.username}{status_indicator}</p>"
if user.note:
self.content += f"<br><small>{user.note}</small>"
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
@ -1036,3 +1086,49 @@ class TimelineView(AccessibleTreeWidget):
"""Announce the current item for screen readers"""
# This will be handled by the AccessibleTreeWidget
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()